diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 57a469ad1fa..f857a6195dc 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -28865,6 +28865,9 @@ importers: '@hcengineering/core': specifier: workspace:^0.7.26 version: link:../../foundations/core/packages/core + '@hcengineering/notification': + specifier: workspace:^0.7.0 + version: link:../notification '@hcengineering/platform': specifier: workspace:^0.7.20 version: link:../../foundations/core/packages/platform @@ -29092,9 +29095,18 @@ importers: '@hcengineering/workbench-resources': specifier: workspace:^0.7.0 version: link:../workbench-resources + '@tanstack/svelte-virtual': + specifier: ^3.13.6 + version: 3.13.31(svelte@4.2.20) fast-equals: specifier: ^5.2.2 version: 5.3.2 + html2canvas: + specifier: ^1.4.1 + version: 1.4.1 + jspdf: + specifier: ^2.5.2 + version: 2.5.2 svelte: specifier: ^4.2.20 version: 4.2.20 @@ -44474,6 +44486,14 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@tanstack/svelte-virtual@3.13.31': + resolution: {integrity: sha512-2R3A19VhlOoOMhCkKaKRgJHPFEBmVBSt4fKBqTMYolj7her0XlVDoTdOUbdBm2kpds1DVEOZFh7YdJw0wmjHzQ==} + peerDependencies: + svelte: ^3.48.0 || ^4.0.0 || ^5.0.0 + + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@telegraf/entity@0.5.0': resolution: {integrity: sha512-4oHOoXcrNaK44zPq4GuTgMmUvCSQxJRVAuPFzVtSeiKzCBJnLeYblsMqWotokhrZSDnNpunC1sxhqI3iVYa/sg==} @@ -45176,6 +45196,9 @@ packages: '@types/querystringify@2.0.2': resolution: {integrity: sha512-7d6OQK6pJ//zE32XLK3vI6GHYhBDcYooaRco9cKFGNu59GVatL5+u7rkiAViq44DxDTd/7QQNBWSDHfJGBz/Pw==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} @@ -45721,6 +45744,11 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + atob@2.1.2: + resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} + engines: {node: '>= 4.5.0'} + hasBin: true + atomically@1.7.0: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} @@ -45817,6 +45845,10 @@ packages: bare-url@2.3.2: resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -45939,6 +45971,11 @@ packages: btoa-lite@1.0.0: resolution: {integrity: sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==} + btoa@1.2.1: + resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==} + engines: {node: '>= 0.4.0'} + hasBin: true + buffer-alloc-unsafe@1.1.0: resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} @@ -46069,6 +46106,10 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + centra@2.7.0: resolution: {integrity: sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==} @@ -46472,6 +46513,9 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-loader@5.2.7: resolution: {integrity: sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==} engines: {node: '>= 10.13.0'} @@ -46981,6 +47025,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@2.5.9: + resolution: {integrity: sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==} + dompurify@3.3.0: resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} @@ -47614,6 +47661,9 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-api@0.10.4: resolution: {integrity: sha512-RVBXJGmsnQxokdpy264pmsdBjbUuxE6QT2xxhOrO2pzwTetbTNoWVFgkONFWmopm5mellsXrQIQhMY9fjufi9g==} @@ -48107,6 +48157,10 @@ packages: webpack: optional: true + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + htmlparser2@10.0.0: resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} @@ -48848,6 +48902,9 @@ packages: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} + jspdf@2.5.2: + resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==} + jszip@2.7.0: resolution: {integrity: sha512-JIsRKRVC3gTRo2vM4Wy9WBC3TRcfnIZU8k65Phi3izkvPH975FowRYtKGT6PxevA0XnJ/yO8b0QwV0ydVyQwfw==} @@ -50043,6 +50100,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} @@ -50469,6 +50529,9 @@ packages: resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} engines: {node: '>=12'} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + random-bytes@1.0.0: resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} engines: {node: '>= 0.8'} @@ -50641,6 +50704,10 @@ packages: rfc6902@5.1.2: resolution: {integrity: sha512-zxcb+PWlE8PwX0tiKE6zP97THQ8/lHmeiwucRrJ3YFupWEmp25RmFSlB1dNTqjkovwqG4iq+u1gzJMBS3um8mA==} + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -51043,6 +51110,10 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} @@ -51308,6 +51379,10 @@ packages: resolution: {integrity: sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==} engines: {node: '>=16'} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + svgo-loader@3.0.3: resolution: {integrity: sha512-6YdWYge3h0aCb8xHvPhGP4hofIU1OWfZm0I8bteab7hddeRN4fl3aIkN8Z/ZB/ji9QrMOd6C8wT8F1p31GUwuQ==} @@ -51384,6 +51459,9 @@ packages: text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -51788,6 +51866,9 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -55571,6 +55652,13 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@tanstack/svelte-virtual@3.13.31(svelte@4.2.20)': + dependencies: + '@tanstack/virtual-core': 3.17.3 + svelte: 4.2.20 + + '@tanstack/virtual-core@3.17.3': {} + '@telegraf/entity@0.5.0': dependencies: '@telegraf/types': 7.1.0 @@ -56383,6 +56471,9 @@ snapshots: '@types/querystringify@2.0.2': {} + '@types/raf@3.4.3': + optional: true + '@types/range-parser@1.2.7': {} '@types/readdir-glob@1.1.5': @@ -57036,6 +57127,8 @@ snapshots: at-least-node@1.0.0: {} + atob@2.1.2: {} + atomically@1.7.0: {} autolinker@4.0.0: @@ -57154,6 +57247,8 @@ snapshots: bare-path: 3.0.0 optional: true + base64-arraybuffer@1.0.2: {} + base64-js@1.5.1: {} base64url@3.0.1: {} @@ -57272,6 +57367,8 @@ snapshots: btoa-lite@1.0.0: {} + btoa@1.2.1: {} + buffer-alloc-unsafe@1.1.0: {} buffer-alloc@1.2.0: @@ -57440,6 +57537,18 @@ snapshots: caniuse-lite@1.0.30001793: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.28.4 + '@types/raf': 3.4.3 + core-js: 3.46.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + centra@2.7.0: dependencies: follow-redirects: 1.15.11 @@ -57896,6 +58005,10 @@ snapshots: crypto-js@4.2.0: {} + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + css-loader@5.2.7(webpack@5.102.1): dependencies: icss-utils: 5.1.0(postcss@8.5.6) @@ -58434,6 +58547,9 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@2.5.9: + optional: true + dompurify@3.3.0: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -59264,6 +59380,8 @@ snapshots: fecha@4.2.3: {} + fflate@0.8.2: {} + file-api@0.10.4: dependencies: File: 0.10.2 @@ -59882,6 +60000,11 @@ snapshots: optionalDependencies: webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + htmlparser2@10.0.0: dependencies: domelementtype: 2.3.0 @@ -60902,6 +61025,18 @@ snapshots: ms: 2.1.3 semver: 7.7.3 + jspdf@2.5.2: + dependencies: + '@babel/runtime': 7.28.4 + atob: 2.1.2 + btoa: 1.2.1 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.46.0 + dompurify: 2.5.9 + html2canvas: 1.4.1 + jszip@2.7.0: dependencies: pako: 1.0.11 @@ -61713,7 +61848,7 @@ snapshots: node-loader@2.0.0(webpack@5.102.1): dependencies: loader-utils: 2.0.4 - webpack: 5.102.1 + webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) node-localstorage@2.2.1: dependencies: @@ -62145,6 +62280,9 @@ snapshots: pend@1.2.0: {} + performance-now@2.1.0: + optional: true + periscopic@3.1.0: dependencies: '@types/estree': 1.0.8 @@ -62631,6 +62769,11 @@ snapshots: quick-lru@6.1.2: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + random-bytes@1.0.0: {} randombytes@2.1.0: @@ -62810,6 +62953,9 @@ snapshots: rfc6902@5.1.2: {} + rgbcolor@1.0.1: + optional: true + rimraf@2.7.1: dependencies: glob: 7.2.3 @@ -63309,6 +63455,9 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackblur-canvas@2.7.0: + optional: true + standardwebhooks@1.0.0: dependencies: '@stablelib/base64': 1.0.1 @@ -63603,6 +63752,9 @@ snapshots: magic-string: 0.30.21 periscopic: 3.1.0 + svg-pathdata@6.0.3: + optional: true + svgo-loader@3.0.3: dependencies: loader-utils: 2.0.4 @@ -63778,6 +63930,10 @@ snapshots: text-hex@1.0.0: {} + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + text-table@0.2.0: {} thenify-all@1.6.0: @@ -64215,6 +64371,10 @@ snapshots: utils-merge@1.0.1: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + uuid@11.1.0: {} uuid@3.4.0: {} diff --git a/foundations/server/packages/elastic/src/adapter.ts b/foundations/server/packages/elastic/src/adapter.ts index 1da9e3711e6..0fde83dfd03 100644 --- a/foundations/server/packages/elastic/src/adapter.ts +++ b/foundations/server/packages/elastic/src/adapter.ts @@ -314,17 +314,71 @@ class ElasticAdapter implements FullTextAdapter { from: number | undefined ): Promise { if (query.$search === undefined) return [] + const raw = String(query.$search) + // Route field-targeted queries (e.g. `searchTitle:value`, `identifier:HULY-`, + // `comments.message:foo`) through `query_string` so ES can parse the + // field-targeted clauses and apply per-field boosts. Restrict the + // detector to the set of fields we actually index — typing a bare colon + // such as `POC: design review` or a URL must NOT silently route to + // `query_string` (which would throw a parsing exception and surface as + // zero hits). Anything else falls back to `simple_query_string` for full + // backwards compatibility. + // KEEP IN SYNC with packages/ui SearchInputAdvanced.encoder.ts + // ES_NATIVE_FIELDS (and vice versa). The client only emits a `field:value` + // clause for a field it believes the server recognises; if the two lists + // drift, a client-routed clause hits a field the adapter does not treat as + // query_string and silently fails to parse. + // + // SECURITY (M-VF5): `query_string` lets a raw `field:value` clause target + // ANY indexed field (e.g. `space:`, `modifiedBy:`, `attachedTo:`), + // not just the `fields` whitelist below (that list only picks the DEFAULT + // fields for bare terms). This is intentionally NOT gated here because the + // hits this method returns are never authoritative results — they are only a + // candidate id-set. The two enclosing guards make field-targeting safe: + // 1. `workspaceId` is a hard `bool.must` term (see the request below), so + // no clause can reach another workspace's documents. + // 2. Within the workspace, every consumer re-filters by space ACL. The + // only caller is pods/fulltext `/api/v1/search` → FullTextMiddleware, + // which sits BELOW SpaceSecurityMiddleware in the pipeline. That + // middleware runs `provideFindAll({ _id: { $in: }, ...q })` + // where `q` still carries the space constraint SpaceSecurityMiddleware + // injected upstream (or, in its >85%-allowed fast path, post-filters + // the result via `clientFilterSpaces`). A forbidden-space doc surfaced + // by a crafted `space:` clause is therefore dropped at the DB findAll / + // result filter and never reaches the client — no content or existence + // oracle. Verified 2026-07-07 against server-pipeline middleware order. + // + const KNOWN_FIELD_RE = + /(^|\s)(searchTitle|searchShortTitle|identifier|description\.plain|comments\.message|fulltextSummary)\s*:/i + const usesQueryString = KNOWN_FIELD_RE.test(raw) + const queryBlock: any = usesQueryString + ? { + query_string: { + query: raw, + fields: [ + 'searchTitle^3', + 'searchShortTitle^2', + 'identifier^2', + 'description.plain', + 'comments.message^0.7', + 'fulltextSummary' + ], + default_operator: 'AND', + allow_leading_wildcard: false + } + } + : { + simple_query_string: { + query: raw, + analyze_wildcard: true, + flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE', + default_operator: 'and' + } + } const request: any = { bool: { must: [ - { - simple_query_string: { - query: query.$search, - analyze_wildcard: true, - flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE', - default_operator: 'and' - } - }, + queryBlock, { term: { workspaceId diff --git a/models/server-tracker/src/index.ts b/models/server-tracker/src/index.ts index 579918fa8e9..0c508d42436 100644 --- a/models/server-tracker/src/index.ts +++ b/models/server-tracker/src/index.ts @@ -73,6 +73,14 @@ export function createModel (builder: Builder): void { } }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnDependencyShiftRequest, + txMatch: { + _class: core.class.TxCreateDoc, + objectClass: tracker.class.DependencyShiftRequest + } + }) + builder.mixin( tracker.ids.AssigneeNotification, notification.class.NotificationType, diff --git a/models/tracker/src/__tests__/migration.test.ts b/models/tracker/src/__tests__/migration.test.ts index 68f452b2415..f54086addcc 100644 --- a/models/tracker/src/__tests__/migration.test.ts +++ b/models/tracker/src/__tests__/migration.test.ts @@ -5,11 +5,12 @@ // you may not use this file except in compliance with the License. // +import { DOMAIN_TX } from '@hcengineering/core' import { DOMAIN_TASK } from '@hcengineering/model-task' import tracker from '@hcengineering/tracker' import { DOMAIN_TRACKER } from '../types' -import { migrateAddStartDate } from '../migration' +import { migrateAddStartDate, migrateRelationActivityAttachment } from '../migration' describe('migrateAddStartDate', () => { it('sets startDate=null on every Issue lacking the field (DOMAIN_TASK)', async () => { @@ -47,3 +48,24 @@ describe('migrateAddStartDate', () => { expect(update).toHaveBeenCalledTimes(2) }) }) + +describe('migrateRelationActivityAttachment', () => { + it('looks up the relation create-tx in DOMAIN_TX (not DOMAIN_MODEL_TX)', async () => { + const findDomains: string[] = [] + const client: any = { + find: jest.fn(async (domain: string, q: any) => { + findDomains.push(domain) + // one broken remove-DUM on the first (DOMAIN_ACTIVITY) call: + if (domain === 'activity') { + return [{ _id: 'dum1', objectId: 'rel1', attachedToClass: 'x', updateCollection: undefined }] + } + return [] // tx lookup returns empty regardless of domain + }), + update: jest.fn(async () => undefined) + } + await migrateRelationActivityAttachment(client) + // The create-tx lookup MUST hit DOMAIN_TX, never DOMAIN_MODEL_TX. + expect(findDomains).toContain(DOMAIN_TX) + expect(findDomains).not.toContain('model_tx') + }) +}) diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index baabea1381a..4ec0cb30bde 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -38,6 +38,8 @@ import { DOMAIN_TRACKER, TClassicProjectTypeData, TComponent, + TDependencyShiftedNotification, + TDependencyShiftRequest, TIssue, TIssueRelation, TIssueStatus, @@ -161,6 +163,34 @@ function defineNotifications (builder: Builder): void { tracker.ids.AssigneeNotification ) + // — Notification on Dependency-Shift. + // The cascade bundle is created client-side as a CommonInboxNotification + // subclass (see `dependency-shift-send.ts`); this NotificationType wires it + // into the user's tracker notification group so settings/Inbox provider + // routing work the same as the assignee type. `field` is bound to the + // `cascadeToken` attribute so the auto-`dueDate` generated type stays + // distinct (and they don't compete on the same `dueDate` notify channel). + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + hidden: false, + generated: false, + label: tracker.string.DependencyShifted, + group: tracker.ids.TrackerNotificationGroup, + field: 'cascadeToken', + txClasses: [core.class.TxCreateDoc], + objectClass: tracker.class.DependencyShiftedNotification, + templates: { + textTemplate: '{sender} shifted {trigger} — {count} dependent issues moved', + htmlTemplate: '

{sender} shifted {trigger} — {count} dependent issues moved

', + subjectTemplate: 'Dependency shift' + }, + defaultEnabled: true + }, + tracker.ids.DependencyShiftedNotification + ) + generateClassNotificationTypes( builder, tracker.class.Issue, @@ -196,7 +226,9 @@ function defineFilters (builder: Builder): void { key: 'milestone', component: view.component.ObjectFilter, showNested: false - } + }, + 'startDate', + 'dueDate' ], ignoreKeys: ['number', 'estimation', 'attachedTo'], getVisibleFilters: tracker.function.GetVisibleFilters @@ -453,7 +485,9 @@ export function createModel (builder: Builder): void { TRelatedIssueTarget, TTypeEstimation, TTypeRemainingTime, - TProjectTargetPreference + TProjectTargetPreference, + TDependencyShiftedNotification, + TDependencyShiftRequest ) builder.mixin(tracker.class.Project, core.class.Class, activity.mixin.ActivityDoc, {}) @@ -596,6 +630,35 @@ export function createModel (builder: Builder): void { tracker.ids.IssueRemovedActivityViewlet ) + // — Activity-Log Remove-Detail Fix. + // Three symmetric viewlets so IssueRelation add/remove/update show up in + // the issue activity feed with a kind+lag+target.title snapshot instead + // of the previous empty "removed related to:" row. The + // RelationActivityPresenter reuses IssueRelationPresenter, which is + // already registered as the ObjectPresenter for tracker.class.IssueRelation + // (models/tracker/src/presenters.ts). + builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, { + objectClass: tracker.class.IssueRelation, + action: 'create', + icon: tracker.icon.Issue, + label: tracker.string.AddedRelation, + component: tracker.component.RelationActivityPresenter + }) + builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, { + objectClass: tracker.class.IssueRelation, + action: 'remove', + icon: tracker.icon.Issue, + label: tracker.string.RemovedRelation, + component: tracker.component.RelationActivityPresenter + }) + builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, { + objectClass: tracker.class.IssueRelation, + action: 'update', + icon: tracker.icon.Issue, + label: tracker.string.UpdatedRelation, + component: tracker.component.RelationActivityPresenter + }) + builder.createDoc( activity.class.DocUpdateMessageViewlet, core.space.Model, diff --git a/models/tracker/src/migration.ts b/models/tracker/src/migration.ts index a150a2947d6..d9a33a70c9f 100644 --- a/models/tracker/src/migration.ts +++ b/models/tracker/src/migration.ts @@ -16,6 +16,7 @@ import activity, { type DocUpdateMessage } from '@hcengineering/activity' import core, { DOMAIN_MODEL_TX, + DOMAIN_TX, DOMAIN_STATUS, type Ref, type Status, @@ -38,16 +39,20 @@ import { DOMAIN_SPACE } from '@hcengineering/model-core' import { DOMAIN_TASK, migrateDefaultStatusesBase } from '@hcengineering/model-task' import tags from '@hcengineering/tags' import task from '@hcengineering/task' -import tracker, { +import { type Issue, + type IssueRelation, type IssueStatus, type Project, TimeReportDayType, trackerId } from '@hcengineering/tracker' +import view, { type ViewOptionModel } from '@hcengineering/view' import { classicIssueTaskStatuses } from '.' +import tracker from './plugin' import { DOMAIN_TRACKER } from './types' +import { ganttViewOptions, issuesOptions } from './viewlets' async function createDefaultProject (tx: TxOperations): Promise { const current = await tx.findOne(tracker.class.Project, { @@ -181,6 +186,74 @@ export async function migrateAddStartDate (client: MigrationClient): Promise { + const txOp = new TxOperations(client, core.account.System) + + const viewlets = await client.findAll(view.class.Viewlet, { + _id: tracker.viewlet.IssueGantt + }) + if (viewlets.length === 0) return + + const desiredOther = ganttViewOptions().other ?? [] + + for (const v of viewlets) { + const current = v.viewOptions ?? { groupBy: [], orderBy: [], other: [] } + const currentOther: ViewOptionModel[] = current.other ?? [] + const existingKeys = new Set(currentOther.map((o) => o.key)) + const missing = desiredOther.filter((o) => !existingKeys.has(o.key)) + if (missing.length === 0) continue + + await txOp.update(v, { + viewOptions: { + ...current, + other: [...currentOther, ...missing] + } + }) + } +} + +// L-G10: SEARCH_VIEW_OPTIONS (showQuickModeSelector / searchScope / +// searchHighlight) are appended to issuesOptions(), which feeds the stored +// List (IssueList) and Kanban (IssueKanban) viewlet docs. As with the Gantt +// viewlet, builder.createDoc is idempotent, so existing workspaces never pick +// up the new `other` entries on upgrade. Merge the missing keys into both +// viewlets by `key` — mirrors addGanttPhase1ViewOptions and is idempotent, so +// a re-run (or a partially-migrated workspace) is a no-op. +async function addSearchViewOptions (client: MigrationUpgradeClient): Promise { + const txOp = new TxOperations(client, core.account.System) + + const targets = [ + { id: tracker.viewlet.IssueList, desired: issuesOptions(false).other ?? [] }, + { id: tracker.viewlet.IssueKanban, desired: issuesOptions(true).other ?? [] } + ] + + for (const t of targets) { + const viewlets = await client.findAll(view.class.Viewlet, { _id: t.id }) + for (const v of viewlets) { + const current = v.viewOptions ?? { groupBy: [], orderBy: [], other: [] } + const currentOther: ViewOptionModel[] = current.other ?? [] + const existingKeys = new Set(currentOther.map((o) => o.key)) + const missing = t.desired.filter((o) => !existingKeys.has(o.key)) + if (missing.length === 0) continue + + await txOp.update(v, { + viewOptions: { + ...current, + other: [...currentOther, ...missing] + } + }) + } + } +} + async function migrateDefaultStatuses (client: MigrationClient, logger: ModelLogger): Promise { const defaultTypeId = tracker.ids.ClassingProjectType const typeDescriptor = tracker.descriptors.ProjectType @@ -375,6 +448,66 @@ async function migrateIssueStatuses (client: MigrationClient): Promise { ) } +/** + * — Activity-Log Remove-Detail Fix. + * + * Legacy IssueRelation removals went through `ops.removeDoc`, which emits + * a bare TxRemoveDoc without parent-issue attachment. The activity + * pipeline therefore wrote a DocUpdateMessage whose attachedTo is the + * relation itself, never showing up in the issue's activity feed. This + * migration re-attaches such DUMs to their parent issue by looking up the + * original TxCreateDoc of the now-removed IssueRelation. Best-effort: + * when the create-tx is missing (db compaction) the DUM still gets + * `updateCollection='relations'` so the activity feed at least shows + * "removed dependency" without the target detail. + * + * Idempotent — once a DUM has Issue-side attachment + `updateCollection`, + * the predicate skips it on subsequent runs. The predicate is a local + * 4-LOC copy of `isBrokenRelationDum` in + * `plugins/tracker-resources/src/components/gantt/lib/relation-activity-migration.ts` + * (cannot import across package layers — models/tracker is below + * tracker-resources). The helper module is the one with unit tests. + */ +export async function migrateRelationActivityAttachment (client: MigrationClient): Promise { + const issueClass = tracker.class.Issue + const dums = await client.find(DOMAIN_ACTIVITY, { + _class: activity.class.DocUpdateMessage, + objectClass: tracker.class.IssueRelation, + action: 'remove' + }) + if (dums.length === 0) return + for (const dum of dums) { + const isBroken = dum.attachedToClass !== issueClass || dum.updateCollection !== 'relations' + if (!isBroken) continue + // Find the create-tx for this relation. The relation objectId remains + // the same across the doc's whole life — that's the key we look up. + const createTxes = await client.find>( + DOMAIN_TX, + { + _class: core.class.TxCreateDoc, + objectId: dum.objectId as Ref + }, + { limit: 1 } + ) + const createTx = createTxes[0] + const patch: Partial = {} + if (createTx?.attachedTo !== undefined && createTx.attachedToClass !== undefined) { + patch.attachedTo = createTx.attachedTo + patch.attachedToClass = createTx.attachedToClass + patch.updateCollection = createTx.collection ?? 'relations' + } else { + // Placeholder: ensure the DUM at least shows up in the parent + // issue's feed by giving it the collection name; we leave + // attachedTo as-is (the runtime DocUpdateMessageObjectValue will + // still try buildRemovedDoc with objectId, which often succeeds + // even when the create-tx for the *DUM's* attachedTo is gone). + if (dum.updateCollection === 'relations') continue + patch.updateCollection = 'relations' + } + await client.update(DOMAIN_ACTIVITY, { _id: dum._id }, patch) + } +} + export const trackerOperation: MigrateOperation = { async preMigrate (client: MigrationClient, logger: ModelLogger, mode): Promise { await tryMigrate(mode, client, trackerId, [ @@ -414,6 +547,22 @@ export const trackerOperation: MigrateOperation = { state: 'gantt-add-startdate', mode: 'upgrade', func: migrateAddStartDate + }, + { + // Phase-2 working-days calendar. The property is optional and + // additive — every existing Project keeps `workingDaysConfig = + // undefined` (legacy calendar-day semantics). The migration entry + // exists only so the tracker state-tracker registers the schema + // version bump; no data is touched. + state: 'gantt-add-working-days-config', + mode: 'upgrade', + func: async () => {} + }, + { + // — Activity-Log Remove-Detail Fix. + state: 'relation-activity-attached-v2', + mode: 'upgrade', + func: migrateRelationActivityAttachment } ]) }, @@ -425,6 +574,14 @@ export const trackerOperation: MigrateOperation = { const tx = new TxOperations(client, core.account.System) await createDefaults(tx) } + }, + { + state: 'add-gantt-phase1-view-options', + func: addGanttPhase1ViewOptions + }, + { + state: 'add-search-view-options', + func: addSearchViewOptions } ]) } diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index ac5d0e46018..f73ab72d8c6 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -71,6 +71,7 @@ export default mergeIds(trackerId, tracker, { IssueList: '' as Ref, IssueTemplateList: '' as Ref, IssueKanban: '' as Ref, + IssueGantt: '' as Ref, MilestoneList: '' as Ref, ComponentList: '' as Ref, ProjectList: '' as Ref, @@ -79,6 +80,8 @@ export default mergeIds(trackerId, tracker, { ids: { TrackerNotificationGroup: '' as Ref, AssigneeNotification: '' as Ref, + // — Notification on Dependency-Shift. + DependencyShiftedNotification: '' as Ref, BaseProjectType: '' as Ref, GuestIssueClassPermission: '' as Ref, ModulePermissionGroup: '' as Ref, diff --git a/models/tracker/src/presenters.ts b/models/tracker/src/presenters.ts index dd609446758..ae0da03870a 100644 --- a/models/tracker/src/presenters.ts +++ b/models/tracker/src/presenters.ts @@ -34,6 +34,22 @@ export function definePresenters (builder: Builder): void { presenter: tracker.component.NotificationIssuePresenter }) + // + // IssueRelation — single-line "FS +Nd → OSTRO-29 Title" presenter so + // the activity feed shows which dependency was added or removed + // (instead of the generic "Dependency" class label). + // + builder.mixin(tracker.class.IssueRelation, core.class.Class, view.mixin.ObjectPresenter, { + presenter: tracker.component.IssueRelationPresenter + }) + + // + // — Dependency-Shift bundle notification renderer. + // + builder.mixin(tracker.class.DependencyShiftedNotification, core.class.Class, view.mixin.ObjectPresenter, { + presenter: tracker.component.DependencyShiftedPresenter + }) + builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.PreviewPresenter, { presenter: tracker.component.IssuePreview }) diff --git a/models/tracker/src/types.ts b/models/tracker/src/types.ts index 249a592ef6d..81055b2f3d2 100644 --- a/models/tracker/src/types.ts +++ b/models/tracker/src/types.ts @@ -51,6 +51,7 @@ import { } from '@hcengineering/model' import attachment from '@hcengineering/model-attachment' import core, { TAttachedDoc, TDoc, TStatus, TType } from '@hcengineering/model-core' +import notification, { TCommonInboxNotification } from '@hcengineering/model-notification' import task, { TTask, TProject as TTaskProject } from '@hcengineering/model-task' import { getEmbeddedLabel, type IntlString } from '@hcengineering/platform' import tags, { type TagElement } from '@hcengineering/tags' @@ -59,6 +60,8 @@ import { type ProjectTargetPreference, type Component, type DependencyKind, + type DependencyShiftedNotification, + type DependencyShiftRequest, type Issue, type IssueChildInfo, type IssueParentInfo, @@ -73,8 +76,10 @@ import { type RelatedClassRule, type RelatedIssueTarget, type RelatedSpaceRule, + type ShiftedIssuePayload, type TimeReportDayType, - type TimeSpendReport + type TimeSpendReport, + type WorkingDaysConfig } from '@hcengineering/tracker' import tracker from './plugin' import { type TaskType } from '@hcengineering/task' @@ -137,6 +142,9 @@ export class TProject extends TTaskProject implements Project { @Prop(Collection(tracker.class.RelatedIssueTarget), tracker.string.RelatedIssues) relatedIssueTargets!: number + + @Prop(TypeRecord(), tracker.string.WorkingDaysConfig) + workingDaysConfig?: WorkingDaysConfig } /** * @public @@ -242,6 +250,15 @@ export class TIssue extends TTask implements Issue { @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.DueDate) declare dueDate: Timestamp | null + // Phase 1.B — soft deadline, independent of dueDate. Optional. + // When set, the Gantt renders a flag marker at this date and flags the + // issue as overdue when dueDate > deadline. Undefined for existing issues + // until the user opts in via the Issue editor (Phase 1 ships the + // inline ControlPanel field; a Gantt context-menu shortcut is a + // separate follow-up, see Out-of-scope section). + @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.Deadline) + deadline?: Timestamp | null + @Prop(TypeRef(tracker.class.Milestone), tracker.string.Milestone, { icon: tracker.icon.Milestone }) @Index(IndexKind.Indexed) milestone!: Ref | null @@ -263,6 +280,19 @@ export class TIssue extends TTask implements Issue { @Prop(Collection(time.class.ToDo), getEmbeddedLabel('Action Items')) todos?: CollectionSize + + /** + * — Auto-Scheduling-Toggle. + * + * Optional property so existing issues stay on the default cascade + * behaviour with no migration. `@Hidden` keeps the field out of the + * generic filter/sort UI in `getFiltredKeys`; the dedicated toggle in + * `ControlPanel.svelte` is the supported entry point. Cascade-time + * checks live in `gantt/lib/scheduler.ts` (Step 5b filter). + */ + @Prop(TypeString(), tracker.string.SchedulingMode) + @Hidden() + schedulingMode?: 'auto' | 'manual' } /** * @public @@ -391,6 +421,9 @@ export class TComponent extends TDoc implements Component { @Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files }) attachments?: number + @Prop(TypeNumber(), tracker.string.Color) + color?: number + declare space: Ref } @@ -423,6 +456,9 @@ export class TMilestone extends TDoc implements Milestone { @Prop(TypeDate(), tracker.string.TargetDate) targetDate!: Timestamp + @Prop(TypeNumber(), tracker.string.Color) + color?: number + declare space: Ref } @@ -459,3 +495,64 @@ export class TClassicProjectTypeData extends TProject implements RolesAssignment @Mixin(tracker.mixin.IssueTypeData, tracker.class.Issue) @UX(getEmbeddedLabel('Issue'), tracker.icon.Issue) export class TIssueTypeData extends TIssue {} + +/** + * — Notification on Dependency-Shift. + * + * Persisted model class for the cascade-shift bundle notification. Extends + * `CommonInboxNotification` so it inherits inbox/email/push routing for + * free; the cascade-specific payload lives in the (un-`@Prop`'d) fields + * which are still serialised as part of the Doc body — same pattern that + * `TReactionInboxNotification` uses for its `ref`/`emoji` fields. + * + * @public + */ +@Model(tracker.class.DependencyShiftedNotification, notification.class.CommonInboxNotification) +export class TDependencyShiftedNotification extends TCommonInboxNotification implements DependencyShiftedNotification { + @Prop(TypeRef(tracker.class.Issue), tracker.string.Issue) + triggerIssueId!: Ref + + @Prop(TypeString(), tracker.string.Issue) + triggerIssueIdentifier!: string + + @Prop(TypeString(), tracker.string.Issue) + triggerIssueTitle!: string + + triggerUserId!: AccountUuid + + shiftedIssues!: ShiftedIssuePayload[] + + @Prop(TypeString(), tracker.string.DependencyShifted) + cascadeToken!: string +} + +/** + * — Notification on Dependency-Shift. + * + * Short-lived signal doc (DOMAIN_TRACKER) a Gantt client writes into the + * project space after a cascade commit. The `OnDependencyShiftRequest` server + * trigger consumes it, dispatches notifications privileged, and removes it. It + * deliberately carries no `triggerUserId` — the trigger derives the author + * from `tx.modifiedBy` (anti-spoofing). + * + * @public + */ +@Model(tracker.class.DependencyShiftRequest, core.class.Doc, DOMAIN_TRACKER) +export class TDependencyShiftRequest extends TDoc implements DependencyShiftRequest { + @Prop(TypeRef(tracker.class.Issue), tracker.string.Issue) + triggerIssueId!: Ref + + @Prop(TypeString(), tracker.string.Issue) + triggerIssueIdentifier!: string + + @Prop(TypeString(), tracker.string.Issue) + triggerIssueTitle!: string + + @Prop(TypeRef(tracker.class.Project), tracker.string.Project) + triggerIssueSpace!: Ref + + shiftedIssues!: ShiftedIssuePayload[] + + @Prop(TypeString(), tracker.string.DependencyShifted) + cascadeToken!: string +} diff --git a/models/tracker/src/viewlets.ts b/models/tracker/src/viewlets.ts index 509e00d7d0e..be324613ddb 100644 --- a/models/tracker/src/viewlets.ts +++ b/models/tracker/src/viewlets.ts @@ -23,6 +23,38 @@ import tags from '@hcengineering/tags' import { type ViewOptionModel, type BuildModelKey, type ViewOptionsModel } from '@hcengineering/view' import tracker from './plugin' +// Plan 2 T5 — shared Customize-View knobs reused by both `issuesOptions()` +// (List + Kanban) and the Gantt viewlet block. Keep this list as the single +// source of truth so a change in one place can never disagree with the +// other half of the UI. +const SEARCH_VIEW_OPTIONS: ViewOptionModel[] = [ + { + key: 'showQuickModeSelector', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.ShowQuickModeSelector + }, + { + key: 'searchScope', + type: 'dropdown', + defaultValue: 'all', + values: [ + { id: 'title', label: tracker.string.SearchScopeTitle }, + { id: 'title-description', label: tracker.string.SearchScopeTitleDescription }, + { id: 'all', label: tracker.string.SearchScopeAll } + ], + label: tracker.string.SearchScopeLabel + }, + { + key: 'searchHighlight', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.SearchHighlight + } +] + export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({ groupBy: [ 'status', @@ -75,7 +107,8 @@ export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({ action: view.function.HideArchived, label: view.string.HideArchived }, - ...(!kanban ? [showColorsViewOption] : []) + ...(!kanban ? [showColorsViewOption] : []), + ...SEARCH_VIEW_OPTIONS ] }) @@ -191,6 +224,21 @@ export function issueConfig ( displayProps: { key: key + 'dueDate', compression: true }, props: { kind: 'list' } }, + { + // — Predecessors column. Opt-in via "Configure columns" + // (displayProps.optional = true) because the vast majority of issues + // have zero predecessors and the cell would otherwise be permanently + // empty in a default list. Renders the upstream Issue identifier + // followed by kind+lag in the spec notation, e.g. "PROJ-3 FS+2d"; + // multiple predecessors collapse to "first +N more" with a hover + // tooltip showing every dependency. Sortable is intentionally NOT + // enabled — there is no meaningful total order across predecessor + // lists. See spec 2026-05-14-huly-gantt-predecessor-column-design.md. + key: '', + label: tracker.string.Predecessors, + presenter: tracker.component.PredecessorsColumn, + displayProps: { key: key + 'predecessors', optional: true } + }, { key: '', label: tracker.string.Estimation, @@ -212,6 +260,305 @@ export function issueConfig ( ] } +export function ganttViewOptions (): ViewOptionsModel { + // PR 2 ships a minimal read-only Gantt. Group-by + Show-colors are + // intentionally NOT advertised — the canvas does not honour them yet. + // The two sidebar-column toggles below ARE wired up to GanttSidebar. + return { + groupBy: [], + orderBy: [ + ['startDate', SortingOrder.Ascending], + ['rank', SortingOrder.Ascending], + ['dueDate', SortingOrder.Ascending] + ], + other: [ + { + key: 'ganttShowIssueCode', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttShowIssueCode + }, + { + key: 'ganttShowTitle', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.GanttShowTitle + }, + { + key: 'ganttShowStatus', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.GanttShowStatus + }, + { + // Default-on safety prompt: when set, dragging an issue's bar to a + // new date range shows a confirm dialog before writing the change. + // User feedback 2026-05-11: easy to misclick a bar while panning, + // and a one-click confirm prevents accidental schedule edits. + key: 'ganttConfirmMove', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.GanttConfirmMove + }, + { + // Same idea but for left/right resize handles. + key: 'ganttConfirmResize', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.GanttConfirmResize + }, + { + // PR4a: sidebar column showing predecessor notation (e.g. "12FS+2d"). + // Hidden by default so existing users don't see a new column appear. + // Toggling on requires no migration — the column is purely derived + // from the IssueRelation collection that already exists from PR1. + key: 'ganttShowPredecessors', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttShowPredecessors + }, + { + // PR5: critical-path overlay toggle. When on, critical bars get a red + // border and fill overlay; critical relations get red arrows; non-critical + // bars show a grey slack glyph. + key: 'ganttCriticalPath', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.CriticalPathOn + }, + { + // PR5: slack column in the sidebar. Shows numeric slack days or "CP" badge + // for critical issues. Requires ganttCriticalPath to be meaningful. + key: 'ganttSlackColumn', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.SlackColumn + }, + { + // Phase 1.A — bar label slot (left of bar). + // Default 'none' so existing users see no left-side label. + key: 'ganttBarLabelLeft', + type: 'dropdown', + defaultValue: 'none', + actionTarget: 'display', + label: tracker.string.GanttBarLabelLeft, + values: [ + { id: 'none', label: tracker.string.BarLabelNone }, + { id: 'title', label: tracker.string.BarLabelTitle }, + { id: 'identifier', label: tracker.string.BarLabelIdentifier }, + { id: 'assignee', label: tracker.string.BarLabelAssignee }, + { id: 'priority', label: tracker.string.BarLabelPriority }, + { id: 'status', label: tracker.string.BarLabelStatus }, + { id: 'estimation', label: tracker.string.BarLabelEstimation }, + { id: 'progress', label: tracker.string.BarLabelProgress } + ] + }, + { + // Phase 1.A — bar label slot (inside bar, rendered only if bar > 60px wide). + // Default 'title' preserves the legacy in-bar title rendering. + key: 'ganttBarLabelInside', + type: 'dropdown', + defaultValue: 'title', + actionTarget: 'display', + label: tracker.string.GanttBarLabelInside, + values: [ + { id: 'none', label: tracker.string.BarLabelNone }, + { id: 'title', label: tracker.string.BarLabelTitle }, + { id: 'identifier', label: tracker.string.BarLabelIdentifier }, + { id: 'assignee', label: tracker.string.BarLabelAssignee }, + { id: 'priority', label: tracker.string.BarLabelPriority }, + { id: 'status', label: tracker.string.BarLabelStatus }, + { id: 'estimation', label: tracker.string.BarLabelEstimation }, + { id: 'progress', label: tracker.string.BarLabelProgress } + ] + }, + { + // Phase 1.A — bar label slot (right of bar). + // Default 'none'. + key: 'ganttBarLabelRight', + type: 'dropdown', + defaultValue: 'none', + actionTarget: 'display', + label: tracker.string.GanttBarLabelRight, + values: [ + { id: 'none', label: tracker.string.BarLabelNone }, + { id: 'title', label: tracker.string.BarLabelTitle }, + { id: 'identifier', label: tracker.string.BarLabelIdentifier }, + { id: 'assignee', label: tracker.string.BarLabelAssignee }, + { id: 'priority', label: tracker.string.BarLabelPriority }, + { id: 'status', label: tracker.string.BarLabelStatus }, + { id: 'estimation', label: tracker.string.BarLabelEstimation }, + { id: 'progress', label: tracker.string.BarLabelProgress } + ] + }, + { + // Phase 1.E — opt-in for Quick-Info-Popover. + // false (default = legacy) = single-click only selects + focuses + // the bar (no popup, no editor); double-click on the bar opens + // the full editor as before. No behaviour change for existing users. + // true = single-click selects + focuses AND opens the lightweight + // Quick-Info popover. Double-click continues to open the full + // editor on top of the popover. + key: 'ganttQuickInfoOnClick', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttQuickInfoOnClick + }, + { + // Extended sidebar grid. When on, the sidebar renders a sortable + // header row + per-column cells (identifier, title, predecessors, + // slack, plus any toggled columns below). When off, the legacy + // compact layout is preserved. + key: 'ganttSidebarColumnsExtended', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttSidebarColumnsExtended + }, + // Per-column visibility toggles for the extended sidebar. Hidden when + // ganttSidebarColumnsExtended is off. Identifier + Title + Predecessors + // + Slack are always shown (default column set). + { + key: 'ganttSidebarShowStatus', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttSidebarShowStatus, + dependsOn: 'ganttSidebarColumnsExtended' + }, + { + key: 'ganttSidebarShowPriority', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttSidebarShowPriority, + dependsOn: 'ganttSidebarColumnsExtended' + }, + { + key: 'ganttSidebarShowAssignee', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttSidebarShowAssignee, + dependsOn: 'ganttSidebarColumnsExtended' + }, + { + key: 'ganttSidebarShowEstimation', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttSidebarShowEstimation, + dependsOn: 'ganttSidebarColumnsExtended' + }, + { + key: 'ganttSidebarShowStartDate', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttSidebarShowStartDate, + dependsOn: 'ganttSidebarColumnsExtended' + }, + { + key: 'ganttSidebarShowDueDate', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttSidebarShowDueDate, + dependsOn: 'ganttSidebarColumnsExtended' + }, + { + key: 'ganttSidebarShowDeadline', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttSidebarShowDeadline, + dependsOn: 'ganttSidebarColumnsExtended' + }, + { + key: 'ganttSidebarShowProgress', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttSidebarShowProgress, + dependsOn: 'ganttSidebarColumnsExtended' + }, + { + // Phase 3b: group-by swimlanes. Issues group into horizontal lanes + // by status/priority/assignee/component/milestone/label. Default + // 'none' preserves the legacy hierarchy view bit-for-bit. The + // sidebar shows a chevron + label + count header per lane; the + // canvas paints a tint band behind each header. + key: 'ganttGroupBy', + type: 'dropdown', + defaultValue: 'none', + values: [ + { id: 'none', label: tracker.string.GanttGroupByNone }, + { id: 'status', label: tracker.string.GanttGroupByStatus }, + { id: 'priority', label: tracker.string.GanttGroupByPriority }, + { id: 'assignee', label: tracker.string.GanttGroupByAssignee }, + { id: 'component', label: tracker.string.GanttGroupByComponent }, + { id: 'milestone', label: tracker.string.GanttGroupByMilestone }, + { id: 'label', label: tracker.string.GanttGroupByLabel } + ], + actionTarget: 'display', + label: tracker.string.GanttGroupBy + }, + { + // Phase 3.10 — overlay toggles. When past-due is on, overdue bars + // get a diagonal red-stripe overlay; when blocked is on, bars with + // an unsatisfied predecessor get a hatch overlay. Both default ON + // so new users immediately see the visual cues. + key: 'ganttShowPastDueOverlay', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.GanttShowPastDueOverlay + }, + { + key: 'ganttShowBlockedOverlay', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.GanttShowBlockedOverlay + }, + { + // Sub-issue progress fill: when ON, parent bars show a secondary + // fill proportional to child-issue completion. Default OFF to keep + // the chart uncluttered for teams that don't use sub-issues. + key: 'ganttShowSubIssueProgress', + type: 'toggle', + defaultValue: false, + actionTarget: 'display', + label: tracker.string.GanttShowSubIssueProgress + }, + // Plan-2 — search-scope / quick-filter / highlight toggles. + ...SEARCH_VIEW_OPTIONS + ] + } +} + +export function ganttConfig (): BuildModelKey[] { + // Minimal config — Gantt drives its own column layout. + return [ + { + key: '', + presenter: tracker.component.PriorityEditor, + label: tracker.string.Priority, + props: { kind: 'list', size: 'small' } + }, + { key: '', presenter: tracker.component.IssuePresenter, label: tracker.string.Issue } + ] +} + export function defineViewlets (builder: Builder): void { builder.createDoc( view.class.ViewletDescriptor, @@ -224,6 +571,17 @@ export function defineViewlets (builder: Builder): void { tracker.viewlet.Kanban ) + builder.createDoc( + view.class.ViewletDescriptor, + core.space.Model, + { + label: tracker.string.Gantt, + icon: tracker.icon.Gantt, + component: tracker.component.GanttView + }, + tracker.viewlet.Gantt + ) + builder.createDoc( view.class.Viewlet, core.space.Model, @@ -501,6 +859,23 @@ export function defineViewlets (builder: Builder): void { tracker.viewlet.IssueKanban ) + // Gantt is registered AFTER List + Kanban so List remains the default + // viewlet (ViewletSelector falls back to viewlets[0] when no preference + // is saved). Putting Gantt last avoids surprising users with an empty + // canvas on first visit. + builder.createDoc( + view.class.Viewlet, + core.space.Model, + { + attachTo: tracker.class.Issue, + descriptor: tracker.viewlet.Gantt, + viewOptions: ganttViewOptions(), + configOptions: { strict: true, hiddenKeys: ['title'] }, + config: ganttConfig() + }, + tracker.viewlet.IssueGantt + ) + const componentListViewOptions: ViewOptionsModel = { groupBy: ['lead', 'createdBy', 'modifiedBy'], orderBy: [ diff --git a/packages/importer/src/huly/huly.ts b/packages/importer/src/huly/huly.ts index 9a272711e6c..d8e855a4ade 100644 --- a/packages/importer/src/huly/huly.ts +++ b/packages/importer/src/huly/huly.ts @@ -75,6 +75,24 @@ export interface HulyIssueHeader { estimation?: number // in hours remainingTime?: number // in hours comments?: HulyComment[] + // Phase 2 — schedule fields surfaced in the Gantt viewlet. + // Dates are ISO YYYY-MM-DD; the importer treats them as UTC midnight. + startDate?: string + dueDate?: string + // Phase 1.B — independent soft deadline. Renders a flag marker in + // the Gantt; turns red+pulsing when dueDate > deadline. + deadline?: string + // Foreign-key references to Component / Milestone by their human + // label. Looked up by label at build time against the components/ + // milestones declared on the parent Project's YAML. Unknown labels + // are reported as an importer error. + component?: string + milestone?: string + // Phase 2 — Finish-to-Start dependency list, by predecessor issue + // identifier (e.g. 'GAME-3') or by relative front-matter title. + // Resolved at build time after all issue identifiers are assigned. + // Limited to FS kind for the first cut. + predecessors?: string[] } export interface HulySpaceSettings { @@ -89,12 +107,33 @@ export interface HulySpaceSettings { emoji?: string } +export interface HulyProjectComponent { + label: string + description?: string +} + +export interface HulyProjectMilestone { + label: string + description?: string + // ISO date YYYY-MM-DD (UTC midnight) + targetDate: string + // Optional ISO start date + startDate?: string +} + export interface HulyProjectSettings extends HulySpaceSettings { class: 'tracker:class:Project' identifier: string id?: 'tracker:project:DefaultProject' projectType?: string defaultIssueStatus?: string + // Phase 2 — declarative components + milestones on the project. + // Issues reference them by label (case-sensitive) via their + // `component` / `milestone` front-matter fields. Each entry is + // materialised as a single tracker.class.Component / Milestone doc + // in the project's space. + components?: HulyProjectComponent[] + milestones?: HulyProjectMilestone[] } export interface HulyTeamspaceSettings extends HulySpaceSettings { @@ -404,6 +443,41 @@ export class HulyFormatImporter { this.metadataRegistry.setRefMetadata(issuePath, tracker.class.Issue, `${projectIdentifier}-${issueNumber}`) + const parseIsoDate = (iso?: string): number | undefined => { + if (iso === undefined || iso === null || String(iso).trim() === '') return undefined + const s = String(iso).trim() + // Phase 2 — relative-date placeholders for the Game Design + // Example and similar demo workspaces. Forms supported: + // today — UTC midnight of install day + // today+5d — N days after install + // today-3d — N days before install + // today+2w — N weeks + // today+1mo — N calendar months + // + // Note: bare `today...` syntax (not `${today...}`) so the + // global UnifiedFormatParser doesn't try to resolve it as + // a script-level variable and throw 'Variable not found'. + const relMatch = s.match(/^today(?:([+-])(\d+)(d|w|mo))?$/) + if (relMatch !== null) { + const today = new Date() + today.setUTCHours(0, 0, 0, 0) + if (relMatch[1] === undefined) return today.getTime() + const sign = relMatch[1] === '+' ? 1 : -1 + const n = Number(relMatch[2]) * sign + if (relMatch[3] === 'd') return today.getTime() + n * 86_400_000 + if (relMatch[3] === 'w') return today.getTime() + n * 7 * 86_400_000 + if (relMatch[3] === 'mo') { + const r = new Date(today.getTime()) + r.setUTCMonth(r.getUTCMonth() + n) + return r.getTime() + } + return today.getTime() + } + const [y, m, d] = s.split('-').map(Number) + if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return undefined + return Date.UTC(y, m - 1, d) + } + const issue: ImportIssue = { id: this.metadataRegistry.getRef(issuePath) as Ref, class: tracker.class.Issue, @@ -416,7 +490,14 @@ export class HulyFormatImporter { remainingTime: issueHeader.remainingTime, comments: await this.processComments(currentPath, issueHeader.comments), subdocs: [], // Will be added via builder - assignee: this.findPersonByName(issueHeader.assignee) + assignee: this.findPersonByName(issueHeader.assignee), + // Phase 2 — Gantt scheduling fields + startDate: parseIsoDate(issueHeader.startDate), + dueDate: parseIsoDate(issueHeader.dueDate), + deadline: parseIsoDate(issueHeader.deadline), + componentLabel: issueHeader.component, + milestoneLabel: issueHeader.milestone, + predecessors: issueHeader.predecessors } builder.addIssue(projectPath, issuePath, issue, parentIssuePath) @@ -613,6 +694,29 @@ export class HulyFormatImporter { } private async processProject (data: HulyProjectSettings): Promise { + const parseIsoDate = (iso?: string): number | undefined => { + if (iso === undefined || iso === null || String(iso).trim() === '') return undefined + const s = String(iso).trim() + const relMatch = s.match(/^today(?:([+-])(\d+)(d|w|mo))?$/) + if (relMatch !== null) { + const today = new Date() + today.setUTCHours(0, 0, 0, 0) + if (relMatch[1] === undefined) return today.getTime() + const sign = relMatch[1] === '+' ? 1 : -1 + const n = Number(relMatch[2]) * sign + if (relMatch[3] === 'd') return today.getTime() + n * 86_400_000 + if (relMatch[3] === 'w') return today.getTime() + n * 7 * 86_400_000 + if (relMatch[3] === 'mo') { + const r = new Date(today.getTime()) + r.setUTCMonth(r.getUTCMonth() + n) + return r.getTime() + } + return today.getTime() + } + const [y, m, d] = s.split('-').map(Number) + if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return undefined + return Date.UTC(y, m - 1, d) + } return { class: tracker.class.Project, id: data.id as Ref, @@ -626,7 +730,15 @@ export class HulyFormatImporter { defaultIssueStatus: data.defaultIssueStatus !== undefined ? { name: data.defaultIssueStatus } : undefined, owners: data.owners !== undefined ? data.owners.map((name) => this.findAccountByName(name)) : [], members: data.members !== undefined ? data.members.map((name) => this.findAccountByName(name)) : [], - docs: [] + docs: [], + // Phase 2 — declarative components + milestones + components: data.components, + milestones: data.milestones?.map((m) => ({ + label: m.label, + description: m.description, + targetDate: parseIsoDate(m.targetDate) ?? Date.now(), + startDate: parseIsoDate(m.startDate) + })) } } diff --git a/packages/importer/src/importer/importer.ts b/packages/importer/src/importer/importer.ts index 5c5d30b7b5b..37bb911c5cb 100644 --- a/packages/importer/src/importer/importer.ts +++ b/packages/importer/src/importer/importer.ts @@ -140,6 +140,24 @@ export interface ImportProject extends ImportSpace { projectType?: ImportProjectType defaultIssueStatus?: ImportStatus description?: string + // Phase 2 — declarative components + milestones associated with + // this project. The importer materialises one Component / Milestone + // doc per entry and exposes the labels to ImportIssue look-ups. + components?: ImportProjectComponent[] + milestones?: ImportProjectMilestone[] +} + +export interface ImportProjectComponent { + label: string + description?: string +} + +export interface ImportProjectMilestone { + label: string + description?: string + // UTC midnight epoch ms; HulyFormatImporter parses YYYY-MM-DD inputs. + targetDate: number + startDate?: number } export interface ImportIssue extends ImportDoc { @@ -152,6 +170,23 @@ export interface ImportIssue extends ImportDoc { estimation?: number remainingTime?: number comments?: ImportComment[] + // Phase 2 — Gantt-scheduling fields. Dates are UTC midnight epoch + // ms; HulyFormatImporter parses YYYY-MM-DD inputs. + startDate?: number + dueDate?: number + // Phase 1.B — soft deadline; renders a flag marker in the Gantt. + deadline?: number + // Component / Milestone references — by LABEL at parse time, the + // builder resolves them to actual Ref / Ref + // against the project's `components` / `milestones` arrays at + // build time. Unknown labels cause a validation error. + componentLabel?: string + milestoneLabel?: string + // Phase 2 — finish-to-start dependency list. Each entry is a + // FRONT-MATTER issue title or an absolute identifier (e.g. + // 'GAME-3'). The builder resolves the references to Ref + // and emits one IssueRelation per pair after all issues exist. + predecessors?: string[] } export interface ImportComment { @@ -434,12 +469,192 @@ export class WorkspaceImporter { throw new Error('Project not found: ' + projectId) } + // Phase 2 — create Component / Milestone docs declared on the + // project. The maps keep them addressable by label for later + // resolution from issue front-matter. + const componentIds = new Map>() + const milestoneIds = new Map>() + if (project.components !== undefined) { + for (const c of project.components) { + const id = generateId() + await this.client.createDoc( + tracker.class.Component, + projectId, + { + label: c.label, + description: c.description ?? '', + lead: null, + comments: 0, + attachments: 0 + }, + id + ) + componentIds.set(c.label, id) + } + } + if (project.milestones !== undefined) { + for (const m of project.milestones) { + const id = generateId() + await this.client.createDoc( + tracker.class.Milestone, + projectId, + { + label: m.label, + description: m.description ?? '', + status: 0, // MilestoneStatus.Planned + comments: 0, + attachments: 0, + startDate: m.startDate ?? null, + targetDate: m.targetDate + }, + id + ) + milestoneIds.set(m.label, id) + } + } + + // Issue id ↔ identifier index for predecessor resolution after + // all issues are created. + const issueIdByTitle = new Map>() + const issueIdByIdentifier = new Map>() + for (const issue of project.docs) { - await this.createIssueWithSubissues(issue, tracker.ids.NoParent, projectDoc, projectId, []) + await this.createIssueWithSubissuesEx( + issue, + tracker.ids.NoParent, + projectDoc, + projectId, + [], + componentIds, + milestoneIds, + issueIdByTitle, + issueIdByIdentifier + ) + } + + // Resolve predecessors → IssueRelation docs after all issues exist. + const collectAllIssues = (docs: ImportIssue[], out: ImportIssue[] = []): ImportIssue[] => { + for (const d of docs) { + out.push(d) + if (d.subdocs?.length > 0) collectAllIssues(d.subdocs as ImportIssue[], out) + } + return out + } + const allIssues = collectAllIssues(project.docs) + for (const issue of allIssues) { + if (issue.predecessors === undefined || issue.predecessors.length === 0) continue + const targetId = issueIdByTitle.get(issue.title) ?? issue.id + if (targetId === undefined) { + this.logger.error(`Cannot resolve target for predecessors of "${issue.title}"`) + continue + } + for (const predRef of issue.predecessors) { + const fromId = issueIdByIdentifier.get(predRef) ?? issueIdByTitle.get(predRef) + if (fromId === undefined) { + this.logger.error(`Unknown predecessor "${predRef}" for issue "${issue.title}"`) + continue + } + await this.client.addCollection( + tracker.class.IssueRelation, + projectId, + fromId, + tracker.class.Issue, + 'relations', + { target: targetId, kind: 'finish-to-start', lag: 0 } + ) + } } return projectId } + async createIssueWithSubissuesEx ( + issue: ImportIssue, + parentId: Ref, + project: Project, + spaceId: Ref, + parentsInfo: IssueParentInfo[], + componentIds: Map>, + milestoneIds: Map>, + issueIdByTitle: Map>, + issueIdByIdentifier: Map> + ): Promise<{ id: Ref, identifier: string }> { + this.logger.log('Creating issue: ' + issue.title) + const issueResult = await this.createIssueWithSchedule( + issue, + project, + parentId, + spaceId, + parentsInfo, + componentIds, + milestoneIds + ) + issueIdByTitle.set(issue.title, issueResult.id) + issueIdByIdentifier.set(issueResult.identifier, issueResult.id) + + if (issue.subdocs?.length > 0) { + const parentsInfoEx = [ + { + parentId: issueResult.id, + parentTitle: issue.title, + space: spaceId, + identifier: issueResult.identifier + }, + ...parentsInfo + ] + + for (const child of issue.subdocs) { + await this.createIssueWithSubissuesEx( + child as ImportIssue, + issueResult.id, + project, + spaceId, + parentsInfoEx, + componentIds, + milestoneIds, + issueIdByTitle, + issueIdByIdentifier + ) + } + } + + return issueResult + } + + async createIssueWithSchedule ( + issue: ImportIssue, + project: Project, + parentId: Ref, + spaceId: Ref, + parentsInfo: IssueParentInfo[], + componentIds: Map>, + milestoneIds: Map> + ): Promise<{ id: Ref, identifier: string }> { + // Delegate to the original createIssue path, then update the + // scheduling-related fields if the front-matter declared them. + // We do it in two steps to keep `createIssue` backward-compatible + // for callers (Linear/Notion/Trello importers) that don't pass + // the new component/milestone maps. + const base = await this.createIssue(issue, project, parentId, spaceId, parentsInfo) + const patch: Partial = {} + if (issue.startDate !== undefined) patch.startDate = issue.startDate + if (issue.dueDate !== undefined) patch.dueDate = issue.dueDate + if (issue.deadline !== undefined) patch.deadline = issue.deadline + if (issue.componentLabel !== undefined) { + const cid = componentIds.get(issue.componentLabel) + if (cid !== undefined) patch.component = cid + else this.logger.error(`Unknown component "${issue.componentLabel}" on issue "${issue.title}"`) + } + if (issue.milestoneLabel !== undefined) { + const mid = milestoneIds.get(issue.milestoneLabel) + if (mid !== undefined) patch.milestone = mid + else this.logger.error(`Unknown milestone "${issue.milestoneLabel}" on issue "${issue.title}"`) + } + if (Object.keys(patch).length > 0) { + await this.client.updateDoc(tracker.class.Issue, spaceId, base.id, patch) + } + return base + } + async createIssueWithSubissues ( issue: ImportIssue, parentId: Ref, diff --git a/packages/presentation/src/components/DocPopup.svelte b/packages/presentation/src/components/DocPopup.svelte index 9568beeb814..fa09225a2eb 100644 --- a/packages/presentation/src/components/DocPopup.svelte +++ b/packages/presentation/src/components/DocPopup.svelte @@ -52,7 +52,7 @@ export let placeholder: IntlString = presentation.string.Search export let selectedObjects: Ref[] = [] export let shadows: boolean = true - export let width: 'medium' | 'large' | 'full' | 'auto' = 'medium' + export let width: 'medium' | 'large' | 'full' | 'auto' | 'resizable' = 'medium' export let size: 'small' | 'medium' | 'large' = 'large' export let noSearchField: boolean = false @@ -222,6 +222,7 @@ class:full-width={width === 'full'} class:plainContainer={!shadows} class:width-40={width === 'large'} + class:width-resizable={width === 'resizable'} class:auto={width === 'auto'} class:embedded on:keydown={onKeydown} diff --git a/packages/presentation/src/components/ObjectPopup.svelte b/packages/presentation/src/components/ObjectPopup.svelte index 3b467a6813e..5e651b01760 100644 --- a/packages/presentation/src/components/ObjectPopup.svelte +++ b/packages/presentation/src/components/ObjectPopup.svelte @@ -43,7 +43,7 @@ export let selectedObjects: Ref[] = [] export let ignoreObjects: Ref[] = [] export let shadows: boolean = true - export let width: 'medium' | 'large' | 'full' | 'auto' = 'medium' + export let width: 'medium' | 'large' | 'full' | 'auto' | 'resizable' = 'medium' export let size: 'small' | 'medium' | 'large' = 'large' export let searchMode: 'field' | 'fulltext' | 'disabled' | 'spotlight' = 'field' diff --git a/packages/presentation/src/sound.ts b/packages/presentation/src/sound.ts index 6aa8ec906f4..b705b86b889 100644 --- a/packages/presentation/src/sound.ts +++ b/packages/presentation/src/sound.ts @@ -4,7 +4,12 @@ import { getClient } from '.' import notification from '@hcengineering/notification' const sounds = new Map() -const context = new AudioContext() +let context: AudioContext | undefined + +function getAudioContext (): AudioContext { + context ??= new AudioContext() + return context +} export async function isNotificationAllowed (_class?: Ref>): Promise { if (_class === undefined) return false @@ -24,7 +29,7 @@ export async function prepareSound (key: string): Promise { const soundUrl = getMetadata(key as Asset) as string const rawAudio = await fetch(soundUrl) const rawBuffer = await rawAudio.arrayBuffer() - const decodedBuffer = await context.decodeAudioData(rawBuffer) + const decodedBuffer = await getAudioContext().decodeAudioData(rawBuffer) sounds.set(key as Asset, decodedBuffer) } catch (err) { @@ -46,6 +51,10 @@ export async function playSound (soundKey: string, loop = false): Promise<(() => } try { + const context = getAudioContext() + if (context.state === 'suspended') { + await context.resume() + } const audio = context.createBufferSource() audio.buffer = sound audio.loop = loop diff --git a/packages/theme/styles/popups.scss b/packages/theme/styles/popups.scss index 95277987304..198cbcc2ad1 100644 --- a/packages/theme/styles/popups.scss +++ b/packages/theme/styles/popups.scss @@ -168,7 +168,17 @@ width: 100%; max-width: 100%; } - + &.width-resizable { + width: 48rem; + max-width: min(96vw, 80rem); + min-width: 28rem; + height: 36rem; + max-height: min(96vh, 60rem); + min-height: 16rem; + resize: both; + overflow: hidden; + } + &.maxHeight { height: 22rem; } &.autoHeight { max-height: calc(100vh - 2rem); diff --git a/packages/ui/src/components/HighlightedText.helpers.ts b/packages/ui/src/components/HighlightedText.helpers.ts new file mode 100644 index 00000000000..e4ecf167ffc --- /dev/null +++ b/packages/ui/src/components/HighlightedText.helpers.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// +/** + * Strip user-typed prefixes (title:, id:, comments:) from the query before + * matching. The encoded wire-form (searchTitle:, identifier:, + * comments.message:, plus scope-wrapping like searchTitle:(loader)) NEVER + * reaches this function — IssuesView feeds the RAW input. + * + * If the caller ever DOES pass an encoded string by accident (a future + * refactor regresses the raw/encoded split), the helper degrades + * gracefully: it will match the wire-form literally against the title, + * find nothing, and return the full text unmarked. No exception thrown. + */ +const USER_PREFIX_RE = /^\s*(title|id|comments)\s*:\s*/i + +export interface Segment { + text: string + match: boolean +} + +export function splitHighlightSegments (text: string, query: string): Segment[] { + // L-VF3: strip ALL stacked leading prefixes (e.g. "title: id: foo"), not + // just the first one. + let trimmed = query.trim() + let prev = '' + while (trimmed !== prev) { + prev = trimmed + trimmed = trimmed.replace(USER_PREFIX_RE, '').trim() + } + if (trimmed === '') return [{ text, match: false }] + // L-VF3: highlight semantics match search semantics — a multi-word query + // highlights each term independently (alternation), not the phrase verbatim. + const terms = trimmed + .split(/\s+/) + .filter((t) => t.length > 0) + .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + if (terms.length === 0) return [{ text, match: false }] + const re = new RegExp(`(${terms.join('|')})`, 'gi') + const parts = text.split(re) + return parts.map((p, i) => ({ text: p, match: i % 2 === 1 })) +} diff --git a/packages/ui/src/components/HighlightedText.svelte b/packages/ui/src/components/HighlightedText.svelte new file mode 100644 index 00000000000..1608429523a --- /dev/null +++ b/packages/ui/src/components/HighlightedText.svelte @@ -0,0 +1,37 @@ + + + + +{#each segments as seg} + {#if seg.match}{seg.text}{:else}{seg.text}{/if} +{/each} + + diff --git a/packages/ui/src/components/HighlightedText.test.ts b/packages/ui/src/components/HighlightedText.test.ts new file mode 100644 index 00000000000..ea0be3389f9 --- /dev/null +++ b/packages/ui/src/components/HighlightedText.test.ts @@ -0,0 +1,61 @@ +import { splitHighlightSegments } from './HighlightedText.helpers' + +describe('splitHighlightSegments', () => { + it('returns single segment when query is empty', () => { + expect(splitHighlightSegments('hello world', '')).toEqual([{ text: 'hello world', match: false }]) + }) + it('splits the string around a single match (case-insensitive)', () => { + expect(splitHighlightSegments('Telescopic loader — deliv', 'loader')).toEqual([ + { text: 'Telescopic ', match: false }, + { text: 'loader', match: true }, + { text: ' — deliv', match: false } + ]) + }) + it('handles multiple matches', () => { + expect(splitHighlightSegments('aaa bbb aaa', 'aaa')).toEqual([ + { text: '', match: false }, + { text: 'aaa', match: true }, + { text: ' bbb ', match: false }, + { text: 'aaa', match: true }, + { text: '', match: false } + ]) + }) + it('strips title: prefix from the query before matching', () => { + expect(splitHighlightSegments('Telescopic loader', 'title:loader')).toEqual([ + { text: 'Telescopic ', match: false }, + { text: 'loader', match: true }, + { text: '', match: false } + ]) + }) + it('strips id: prefix from the query before matching', () => { + expect(splitHighlightSegments('HULY-51 something', 'id:HULY-')).toEqual([ + { text: '', match: false }, + { text: 'HULY-', match: true }, + { text: '51 something', match: false } + ]) + }) + it('strips comments: prefix from the query before matching', () => { + expect(splitHighlightSegments('See comments below: fine', 'comments:fine')).toEqual([ + { text: 'See comments below: ', match: false }, + { text: 'fine', match: true }, + { text: '', match: false } + ]) + }) + it('strips ALL stacked leading prefixes, not just the first (L-VF3)', () => { + // `title: id:loader` → strip `title:` then `id:` → highlight `loader`. + expect(splitHighlightSegments('Telescopic loader', 'title: id:loader')).toEqual([ + { text: 'Telescopic ', match: false }, + { text: 'loader', match: true }, + { text: '', match: false } + ]) + }) + it('highlights each term of a multi-word query independently (L-VF3)', () => { + expect(splitHighlightSegments('foo bar baz', 'foo baz')).toEqual([ + { text: '', match: false }, + { text: 'foo', match: true }, + { text: ' bar ', match: false }, + { text: 'baz', match: true }, + { text: '', match: false } + ]) + }) +}) diff --git a/packages/ui/src/components/ModeSelector.svelte b/packages/ui/src/components/ModeSelector.svelte index 62bfd1fecd9..48f14fcd25e 100644 --- a/packages/ui/src/components/ModeSelector.svelte +++ b/packages/ui/src/components/ModeSelector.svelte @@ -28,9 +28,14 @@ name={'modeSelector'} items={modeList} selected={props.mode} + disabled={props.disabled ?? false} + tooltip={props.disabled === true && props.disabledReason !== undefined + ? { label: props.disabledReason, direction: 'bottom' } + : undefined} {kind} {onlyIcons} on:select={(result) => { + if (props.disabled === true) return if (result.detail !== undefined && result.detail.action) result.detail.action() }} /> diff --git a/packages/ui/src/components/SearchInputAdvanced.encoder.ts b/packages/ui/src/components/SearchInputAdvanced.encoder.ts new file mode 100644 index 00000000000..5067c4d6390 --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.encoder.ts @@ -0,0 +1,378 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// +/** + * Encode the raw Lupe input into the wire-string for $search. + * + * Two transformations applied: + * + * 1. **User-prefix → ES-field aliasing.** User-friendly shorthands map to + * the actual indexed field names: + * title:foo → searchTitle:foo + * id:foo → identifier:foo + * comments:foo → comments.message:foo + * ES-native field names (searchTitle:, identifier:, description.plain:, + * comments.message:) pass through verbatim — power users can use them + * directly. + * + * 2. **Bare-term scope wrapping.** When the input contains no prefix at all + * AND the Customize-View `searchScope` is restrictive, wrap the bare + * terms in the field-scope: + * scope=title → searchTitle:() + * scope=title-description → (searchTitle:() OR description.plain:()) + * scope=all → (untouched; the adapter falls back to + * simple_query_string across all indexed text) + * + * The two transformations are independent; an input that already contains + * a prefix is only aliased (transformation 1), never wrapped (transformation + * 2). This matches user intent — they specified a field explicitly. + */ +export type SearchScope = 'title' | 'title-description' | 'all' + +/** Map user-friendly prefixes to ES field names. */ +const PREFIX_ALIAS: Record = { + title: 'searchTitle', + id: 'identifier', + comments: 'comments.message' +} + +/** + * Allowed ES-native fields users may type directly. + * + * KEEP IN SYNC with elastic/src/adapter.ts KNOWN_FIELD_RE (and vice versa). + * Both sides must carry the identical field set so the client only routes a + * `field:value` clause to query_string when the server-side adapter also + * recognises that field; otherwise the clause silently fails to parse. + */ +const ES_NATIVE_FIELDS = new Set([ + 'searchTitle', + 'searchShortTitle', + 'identifier', + 'description.plain', + 'comments.message', + 'fulltextSummary' +]) +/** Lowercase → canonical lookup so mixed-case ES-native prefixes normalise. */ +const ES_NATIVE_CANON = new Map([...ES_NATIVE_FIELDS].map((f) => [f.toLowerCase(), f])) +const USER_PREFIX_KEYS = new Set(Object.keys(PREFIX_ALIAS)) + +/** + * Matches `field:` tokens at the start of a substring. Restricted to known + * user-prefixes + ES-native fields so a stray colon (URLs, times, code) is + * NOT mistaken for a field-targeted query (which would route to + * query_string and silently fail on parse errors). Anything else is passed + * through verbatim and the adapter keeps the simple_query_string path. + */ +function buildKnownPrefixRe (): RegExp { + const fields = [...USER_PREFIX_KEYS, ...ES_NATIVE_FIELDS] + // escape dots in `description.plain`, `comments.message` + .map((f) => f.replace(/\./g, '\\.')) + .join('|') + return new RegExp(`(^|\\s)(${fields})\\s*:`, 'gi') +} +const KNOWN_PREFIX_RE = buildKnownPrefixRe() + +function aliasPrefixes (input: string): string { + KNOWN_PREFIX_RE.lastIndex = 0 + return input.replace(KNOWN_PREFIX_RE, (_m, lead: string, field: string) => { + const lower = field.toLowerCase() + // M-VF1: canonicalise both user-shorthands (title → searchTitle) AND + // mixed-case ES-native fields (Identifier → identifier, SearchTitle → + // searchTitle) so the downstream tokenizer's field match — and the ES + // adapter's KNOWN_FIELD_RE — see the exact canonical field name. + const aliased = PREFIX_ALIAS[lower] ?? ES_NATIVE_CANON.get(lower) ?? field + return `${lead}${aliased}:` + }) +} + +function hasKnownPrefix (input: string): boolean { + KNOWN_PREFIX_RE.lastIndex = 0 + return KNOWN_PREFIX_RE.test(input) +} + +/** + * Escape Lucene query_string reserved characters so a bare term we wrap + * in a scope clause (e.g. `searchTitle:(POC: design review)`) does not + * accidentally re-enter field-targeted parsing on the inner `:` (which + * would throw a query_string_parsing_exception in ES and surface as zero + * hits). Covers the full Lucene reserved set; the wrapping parens are + * added by the caller, not by user input, so they stay un-escaped here. + */ +function escapeForQueryString (s: string): string { + // M-VF2: `& | < > =` join the reserved set. `&&`/`||` are the Lucene + // boolean operators and `< > =` open range comparisons — all three throw a + // query_string_parsing_exception when they appear un-escaped inside a + // wrapped field value. Escaping each char keeps the value a literal token. + return s.replace(/[+\-!(){}[\]^"~*?:\\/&|<>=]/g, '\\$&') +} + +/** + * Reserved chars that BREAK ES `query_string` parsing inside a field-value + * position. Narrower than the full Lucene reserved set because we want to + * preserve readable, raw values for the common cases. Specifically: + * + * - `-` is omitted: ES treats `-` mid-term or at end-of-term as literal text + * (identifier-style values like `HULY-51` work without escaping; the only + * problematic position is the very start of a clause where `-` is the NOT + * operator, but a token starting with `-` would already be lexed as a + * bare term, never landing here as a field value). + * - `*` and `?` are omitted: users may legitimately type them as wildcards. + * + * What stays — these literally crash the parser when mid-value: + * `+` `!` `(` `)` `{` `}` `[` `]` `^` `"` `~` `\` `/` `:` + * + * `:` is included because the bare-value regex is greedy across non-whitespace + * (so `title:POC:123` lands here as value `POC:123`); without escaping ES + * query_string would re-parse the inner `:` as another field-targeted + * clause, blowing up the entire query. + */ +const PREFIX_VALUE_RESERVED_RE = /[+!(){}[\]^"~\\/:&|<>=]/ + +/** + * Single-pass tokenizer for the prefix-routed encode path. + * + * Background: previous iterations of `escapePrefixValues` stacked two + * regex passes (clause-replace + whitespace-split). Each new edge case + * (colon-in-value, attached parens, C++ in orphan position, quoted- + * phrase splitting) required another regex tweak and the logic became + * fragile. This tokenizer replaces both passes with one walker that + * emits typed tokens; each token type has exactly one rendering rule. + * + * Token grammar: + * + * ws whitespace run + * bool-op 'AND' | 'OR' | 'NOT' (uppercase, standalone) + * quoted `"...somestring..."` — pass through unchanged + * field-clause `:` where is one of + * - paren-wrapped `(...)` — re-emit with inner escaped + * - quoted phrase `"..."` — pass through + * - bare run `[^\s]+` — escape+wrap if reserved + * bare any other non-whitespace run — escape reserved chars + * + * The tokenizer attempts matches in that priority order at every + * token boundary (positions immediately after whitespace or at start + * of input). + */ + +type Token = + | { kind: 'ws', raw: string } + | { kind: 'bool-op', raw: 'AND' | 'OR' | 'NOT' } + | { kind: 'quoted', raw: string } // includes the surrounding quotes + | { kind: 'field-clause', field: string, value: ClauseValue } + | { kind: 'bare', raw: string } + +type ClauseValue = { kind: 'paren', inner: string } | { kind: 'quoted', inner: string } | { kind: 'bare', raw: string } + +const BOOL_OPS = new Set(['AND', 'OR', 'NOT']) + +function tokenize (input: string): Token[] { + const tokens: Token[] = [] + // Pre-sort field names longest-first so `description.plain` matches + // before `description` would (if ever added). Currently no overlap + // but defensive. + const fields = [...ES_NATIVE_FIELDS].sort((a, b) => b.length - a.length) + + let i = 0 + while (i < input.length) { + const ch = input[i] + + // ws + if (/\s/.test(ch)) { + let end = i + 1 + while (end < input.length && /\s/.test(input[end])) end++ + tokens.push({ kind: 'ws', raw: input.slice(i, end) }) + i = end + continue + } + + // standalone quoted phrase + if (ch === '"') { + const close = input.indexOf('"', i + 1) + if (close >= 0) { + // Boundary check: after close must be EOF or whitespace + if (close + 1 === input.length || /\s/.test(input[close + 1])) { + tokens.push({ kind: 'quoted', raw: input.slice(i, close + 1) }) + i = close + 1 + continue + } + } + // Unbalanced or attached — fall through to bare + } + + // field-clause: known field name at a token boundary, followed by `:` + let matched = false + for (const field of fields) { + if (i + field.length + 1 > input.length) continue + // M-VF1: match the field name case-insensitively; we always emit the + // canonical `field` (never the typed casing) further down. + if (input.slice(i, i + field.length).toLowerCase() !== field.toLowerCase()) continue + if (input[i + field.length] !== ':') continue + // H3: skip whitespace after the colon so `title: foo` / `title : foo` + // route to a field-clause instead of collapsing to an empty value (which + // would leave the bare `field:` token to have its colon escaped → 0 hits). + let valueStart = i + field.length + 1 + while (valueStart < input.length && /\s/.test(input[valueStart])) valueStart++ + + // paren-wrapped value + if (input[valueStart] === '(') { + const close = input.indexOf(')', valueStart + 1) + if (close >= 0) { + tokens.push({ + kind: 'field-clause', + field, + value: { kind: 'paren', inner: input.slice(valueStart + 1, close) } + }) + i = close + 1 + matched = true + break + } + } + + // quoted value + if (input[valueStart] === '"') { + const close = input.indexOf('"', valueStart + 1) + if (close >= 0) { + tokens.push({ + kind: 'field-clause', + field, + value: { kind: 'quoted', inner: input.slice(valueStart + 1, close) } + }) + i = close + 1 + matched = true + break + } + } + + // bare value: run up to whitespace + let end = valueStart + while (end < input.length && !/\s/.test(input[end])) end++ + if (end > valueStart) { + tokens.push({ + kind: 'field-clause', + field, + value: { kind: 'bare', raw: input.slice(valueStart, end) } + }) + i = end + matched = true + break + } + } + if (matched) continue + + // boolean operator (uppercase, standalone — boundary on both sides) + for (const op of BOOL_OPS) { + if (input.slice(i, i + op.length) !== op) continue + const after = i + op.length + if (after !== input.length && !/\s/.test(input[after])) continue + tokens.push({ kind: 'bool-op', raw: op as 'AND' | 'OR' | 'NOT' }) + i = after + matched = true + break + } + if (matched) continue + + // bare token: run up to next whitespace + let end = i + while (end < input.length && !/\s/.test(input[end])) end++ + tokens.push({ kind: 'bare', raw: input.slice(i, end) }) + i = end + } + return tokens +} + +/** Render a single token into its ES-safe wire form. */ +function renderToken (tok: Token): string { + switch (tok.kind) { + case 'ws': + case 'bool-op': + case 'quoted': + return tok.raw + + case 'field-clause': { + const { field, value } = tok + switch (value.kind) { + case 'paren': + // User explicitly wrapped value in parens. Re-emit with the inner + // content escaped so any reserved chars become literal — the + // wrapping parens themselves are ES grouping syntax, not user + // text. + return `${field}:(${escapeForQueryString(value.inner)})` + case 'quoted': + // ES phrase literal; pass inner content through verbatim. + return `${field}:"${value.inner}"` + case 'bare': + // Leading '-' on a field-targeted value would be parsed by ES + // query_string as the Lucene NOT operator (NOT foo), not as + // literal text. Wrap+escape so the minus stays literal. Mid- + // token hyphens (HULY-51, bug-fix) are unaffected — they only + // hit this branch when value.raw does NOT start with '-'. + if (value.raw.startsWith('-')) { + return `${field}:(${escapeForQueryString(value.raw)})` + } + // Clean value: emit bare for a readable wire string. Reserved + // chars present: wrap in parens with full Lucene escape so ES + // query_string parses the value as a single literal token. + if (!PREFIX_VALUE_RESERVED_RE.test(value.raw)) { + return `${field}:${value.raw}` + } + return `${field}:(${escapeForQueryString(value.raw)})` + } + // ClauseValue is exhaustively handled above; this break keeps the + // outer switch free of implicit fall-through (eslint no-fallthrough). + break + } + + case 'bare': { + // Orphan bare token in a prefix-routed query. Once ANY known prefix + // appears, the adapter routes via ES query_string (strict parser), + // so reserved chars here also break the query. Escape per + // PREFIX_VALUE_RESERVED_RE (narrower than the full Lucene set — + // see the constant's JSDoc for why `-`, `*`, `?` are excluded). + // + // Special case: leading '-' would be parsed as Lucene NOT-operator + // even though mid-token '-' is tolerant. Escape the leading minus + // explicitly so the orphan token stays a literal term. + if (tok.raw.startsWith('-')) { + return '\\-' + tok.raw.slice(1).replace(/[+!(){}[\]^"~\\/:&|<>=]/g, '\\$&') + } + if (!PREFIX_VALUE_RESERVED_RE.test(tok.raw)) return tok.raw + return tok.raw.replace(/[+!(){}[\]^"~\\/:&|<>=]/g, '\\$&') + } + } +} + +function escapePrefixValues (aliased: string): string { + const tokens = tokenize(aliased) + // M-VF2: a trailing boolean operator (`foo AND`, `title:foo NOT`) has no + // right-hand operand, so ES query_string throws a parse exception. Find the + // last non-whitespace token; if it is a bool-op it is dangling → escape it + // into a literal term instead of emitting it as an operator. + let lastNonWs = -1 + for (let k = tokens.length - 1; k >= 0; k--) { + if (tokens[k].kind !== 'ws') { + lastNonWs = k + break + } + } + return tokens + .map((tok, idx) => (tok.kind === 'bool-op' && idx === lastNonWs ? `\\${tok.raw}` : renderToken(tok))) + .join('') +} + +export function encodeSearch (raw: string, scope: SearchScope): string { + const trimmed = raw.trim() + if (trimmed === '') return '' + const aliased = aliasPrefixes(trimmed) + if (hasKnownPrefix(trimmed)) return escapePrefixValues(aliased) + const safe = escapeForQueryString(aliased) + switch (scope) { + case 'title': + return `searchTitle:(${safe})` + case 'title-description': + return `(searchTitle:(${safe}) OR description.plain:(${safe}))` + case 'all': + default: + return aliased // scope=all routes via simple_query_string; no escaping needed + } +} diff --git a/packages/ui/src/components/SearchInputAdvanced.helpers.test.ts b/packages/ui/src/components/SearchInputAdvanced.helpers.test.ts new file mode 100644 index 00000000000..5eba4e58185 --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.helpers.test.ts @@ -0,0 +1,32 @@ +import { reconcilePropValue } from './SearchInputAdvanced.helpers' + +describe('reconcilePropValue', () => { + // C1 core: parent passes stable value='' while user types 'f'. + // The prop did NOT change (lastProp === '' === propValue), so the + // user's local edit MUST survive. + it('keeps the local edit when the prop value is unchanged', () => { + const r = reconcilePropValue('', '', 'f') + expect(r.value).toBe('f') + expect(r.lastProp).toBe('') + }) + + // A real prop change (route/space switch resets searchRaw) DOES override. + it('adopts a genuinely changed prop value', () => { + const r = reconcilePropValue('newquery', 'oldquery', 'user-typed') + expect(r.value).toBe('newquery') + expect(r.lastProp).toBe('newquery') + }) + + // undefined prop never clobbers local state. + it('never overwrites local state from an undefined prop', () => { + const r = reconcilePropValue(undefined, 'prev', 'typed') + expect(r.value).toBe('typed') + expect(r.lastProp).toBe(undefined) + }) + + // First bind: prop '' equals initial lastProp '' → local (also '') kept. + it('is a no-op on the initial empty binding', () => { + const r = reconcilePropValue('', '', '') + expect(r.value).toBe('') + }) +}) diff --git a/packages/ui/src/components/SearchInputAdvanced.helpers.ts b/packages/ui/src/components/SearchInputAdvanced.helpers.ts new file mode 100644 index 00000000000..369c6d9349e --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.helpers.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// +/** + * Decide the input's local value after a parent-prop change. + * + * C1: the previous inline `$: if (value !== _search && value !== undefined)` + * block read BOTH `value` and `_search`, so Svelte re-ran it on every + * keystroke and clobbered the user's edit back to the (empty) prop. This + * helper compares the prop ONLY against the last prop we adopted — never + * against the live local value — so it fires exactly once per real prop + * change and leaves mid-typing edits intact. + */ +export function reconcilePropValue ( + propValue: string | undefined, + lastProp: string | undefined, + current: string +): { value: string, lastProp: string | undefined } { + if (propValue === lastProp) return { value: current, lastProp } + return { value: propValue ?? current, lastProp: propValue } +} diff --git a/packages/ui/src/components/SearchInputAdvanced.svelte b/packages/ui/src/components/SearchInputAdvanced.svelte new file mode 100644 index 00000000000..188d6116984 --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.svelte @@ -0,0 +1,255 @@ + + + + + + + diff --git a/packages/ui/src/components/SearchInputAdvanced.test.ts b/packages/ui/src/components/SearchInputAdvanced.test.ts new file mode 100644 index 00000000000..0abfa55ba47 --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.test.ts @@ -0,0 +1,221 @@ +import { encodeSearch } from './SearchInputAdvanced.encoder' + +describe('encodeSearch', () => { + // ─── No prefix: bare-term scope expansion ─────────────────────────────── + it('passes bare terms verbatim when scope=all', () => { + expect(encodeSearch('loader', 'all')).toBe('loader') + }) + it('wraps bare terms in searchTitle:(…) when scope=title', () => { + expect(encodeSearch('loader bar', 'title')).toBe('searchTitle:(loader bar)') + }) + it('wraps bare terms in OR-clause when scope=title-description', () => { + expect(encodeSearch('loader', 'title-description')).toBe('(searchTitle:(loader) OR description.plain:(loader))') + }) + + // ─── Prefix aliasing: user-shorthand → ES field name ──────────────────── + it('aliases title: → searchTitle: in the wire string', () => { + expect(encodeSearch('title:loader', 'all')).toBe('searchTitle:loader') + }) + it('aliases id: → identifier:', () => { + expect(encodeSearch('id:HULY-', 'all')).toBe('identifier:HULY-') + expect(encodeSearch('id:HULY-51', 'title')).toBe('identifier:HULY-51') + }) + it('aliases comments: → comments.message:', () => { + expect(encodeSearch('comments:foo', 'all')).toBe('comments.message:foo') + }) + it('preserves prefix-targeted terms when multiple are typed', () => { + expect(encodeSearch('title:loader id:HULY-', 'all')).toBe('searchTitle:loader identifier:HULY-') + }) + + // ─── Edge cases ───────────────────────────────────────────────────────── + it('returns empty string for empty input', () => { + expect(encodeSearch('', 'all')).toBe('') + expect(encodeSearch(' ', 'all')).toBe('') + }) + it('passes ES-native field syntax through unchanged', () => { + // Power users who already know ES fields can bypass the alias. + expect(encodeSearch('searchTitle:loader', 'all')).toBe('searchTitle:loader') + expect(encodeSearch('identifier:HULY-1', 'all')).toBe('identifier:HULY-1') + }) + it('treats unknown prefixes as bare terms (no field-routing)', () => { + // `POC:` is not a known prefix; the user typed a colon in their text, + // not a field-targeted query. The encoder leaves it intact for the + // simple_query_string path (scope=all) and Lucene-escapes the colon + // when wrapping into a query_string field clause (scope=title) so the + // adapter cannot accidentally re-parse it as a nested field selector. + expect(encodeSearch('POC: design review', 'all')).toBe('POC: design review') + expect(encodeSearch('POC: design review', 'title')).toBe('searchTitle:(POC\\: design review)') + }) + it('treats time-of-day "12:30" as bare text and escapes the colon when scoped', () => { + expect(encodeSearch('meeting 12:30', 'all')).toBe('meeting 12:30') + expect(encodeSearch('meeting 12:30', 'title')).toBe('searchTitle:(meeting 12\\:30)') + }) + it('escapes Lucene operators inside scope-wrapped bare terms', () => { + expect(encodeSearch('C++ developer', 'title')).toBe('searchTitle:(C\\+\\+ developer)') + expect(encodeSearch('foo (bar) [baz]', 'title')).toBe('searchTitle:(foo \\(bar\\) \\[baz\\])') + }) + + // ─── Prefix-value escaping ─────────────────────────────────────────────── + // Prefix-targeted inputs were previously sent verbatim to ES query_string, + // which crashes the parser on Lucene-reserved chars like `+` or `/`. + // We now wrap+escape ONLY when the value would otherwise blow up; clean + // values stay readable. + it('wraps + escapes prefix values containing Lucene reserved chars', () => { + expect(encodeSearch('title:C++', 'all')).toBe('searchTitle:(C\\+\\+)') + expect(encodeSearch('comments:foo/bar', 'all')).toBe('comments.message:(foo\\/bar)') + }) + it('treats user-typed parens around a prefix value as ES grouping', () => { + // `title:(scope)` is ambiguous between "literal parens in text" and + // "ES query_string grouping". We pick the latter (more useful to + // power users); a user who wants literal parens can quote the value: + // `title:"(scope)"`. + expect(encodeSearch('title:(scope)', 'all')).toBe('searchTitle:(scope)') + expect(encodeSearch('title:(C++)', 'all')).toBe('searchTitle:(C\\+\\+)') + }) + it('leaves clean prefix values bare so the wire string stays readable', () => { + // No reserved chars → no wrap. Preserves the simple common case. + expect(encodeSearch('title:loader', 'all')).toBe('searchTitle:loader') + expect(encodeSearch('id:HULY-51', 'all')).toBe('identifier:HULY-51') + expect(encodeSearch('comments:fixed', 'all')).toBe('comments.message:fixed') + }) + it('passes quoted prefix values through as phrase literals', () => { + // Quoted phrases are an ES query_string phrase literal — no escape + // needed even when the inner text would otherwise be reserved. + expect(encodeSearch('title:"foo bar"', 'all')).toBe('searchTitle:"foo bar"') + }) + it('preserves boolean operators between prefix clauses', () => { + // Power-user syntax: AND/OR between prefix-targeted clauses must + // pass through, only the values get wrapped when needed. + expect(encodeSearch('title:C++ OR id:HULY-1', 'all')).toBe('searchTitle:(C\\+\\+) OR identifier:HULY-1') + }) + + // ─── Colon-in-value handling ───────────────────────────────────────────── + // The bare-value regex is greedy across non-whitespace so a value can + // contain its own colon. Without escaping that, ES query_string would + // re-parse the inner colon as another field-targeted clause and the + // entire query crashes. The encoder now wraps+escapes such values. + it('wraps + escapes colons inside prefix values', () => { + expect(encodeSearch('title:POC:123', 'all')).toBe('searchTitle:(POC\\:123)') + expect(encodeSearch('title:12:30', 'all')).toBe('searchTitle:(12\\:30)') + expect(encodeSearch('comments:bug:fix', 'all')).toBe('comments.message:(bug\\:fix)') + }) + it('handles colon-in-value alongside other reserved chars', () => { + expect(encodeSearch('title:POC:C++', 'all')).toBe('searchTitle:(POC\\:C\\+\\+)') + }) + it('escapes orphan colons in bare tokens that follow a prefix clause', () => { + // 'title:meeting 12:30' — the 12:30 has no known-field anchor, but + // ES query_string still sees a colon there and tries to parse '12' + // as a field. Second pass escapes orphan colons so they read as + // literal text. + expect(encodeSearch('title:meeting 12:30', 'all')).toBe('searchTitle:meeting 12\\:30') + }) + + // ─── Orphan tokens with reserved chars ─────────────────────────────────── + // Once any prefix appears, the adapter routes via ES query_string so + // EVERY bare token must be parser-safe — not just colon-bearing ones. + it('escapes orphan + signs in tokens that follow a prefix clause', () => { + expect(encodeSearch('title:meeting C++', 'all')).toBe('searchTitle:meeting C\\+\\+') + }) + it('escapes orphan slashes in tokens that follow a prefix clause', () => { + expect(encodeSearch('title:meeting foo/bar', 'all')).toBe('searchTitle:meeting foo\\/bar') + }) + it('escapes orphan parens / brackets / braces in trailing tokens', () => { + expect(encodeSearch('title:meeting foo)', 'all')).toBe('searchTitle:meeting foo\\)') + expect(encodeSearch('title:bug list[0]', 'all')).toBe('searchTitle:bug list\\[0\\]') + }) + it('preserves boolean operators AND/OR/NOT verbatim between orphan tokens', () => { + expect(encodeSearch('title:meeting AND foo OR bar', 'all')).toBe('searchTitle:meeting AND foo OR bar') + }) + it('passes through quoted phrases as orphan tokens', () => { + expect(encodeSearch('title:meeting "release notes"', 'all')).toBe('searchTitle:meeting "release notes"') + }) + it('preserves wildcards * and ? in orphan tokens', () => { + // Wildcards are legitimate ES query_string syntax for prefix / + // single-char match. Leave them un-escaped so the user can type + // them on purpose. + expect(encodeSearch('title:meeting foo*', 'all')).toBe('searchTitle:meeting foo*') + expect(encodeSearch('title:meeting b?r', 'all')).toBe('searchTitle:meeting b?r') + }) + it('preserves hyphens mid-token in orphan tokens (ES tolerant)', () => { + expect(encodeSearch('title:meeting bug-fix', 'all')).toBe('searchTitle:meeting bug-fix') + }) + + // ─── Attached parens in prefix values ──────────────────────────────────── + // The bare-value regex previously stopped before `(` / `)`, so + // `title:foo(bar)` slipped through as `searchTitle:foo(bar)` raw — + // pass 2 saw a known-field prefix on the token and passed it through + // verbatim, never escaping the embedded parens. Now the bare-value + // pattern extends to whitespace, so attached parens get captured as + // part of the value and wrapped+escaped properly. + it('wraps + escapes prefix values with attached parens', () => { + expect(encodeSearch('title:foo(bar)', 'all')).toBe('searchTitle:(foo\\(bar\\))') + expect(encodeSearch('title:foo)', 'all')).toBe('searchTitle:(foo\\))') + }) + it('wraps + escapes prefix values with attached brackets', () => { + expect(encodeSearch('title:list[0]', 'all')).toBe('searchTitle:(list\\[0\\])') + }) + it('keeps user-wrapped parens distinct from attached parens', () => { + // `title:(scope)` — explicit paren-wrap, value is `scope` (no + // reserved chars after stripping the wrap) — passes through bare. + expect(encodeSearch('title:(scope)', 'all')).toBe('searchTitle:(scope)') + // `title:foo(bar)` — bare value with attached parens, wrap+escape. + expect(encodeSearch('title:foo(bar)', 'all')).toBe('searchTitle:(foo\\(bar\\))') + }) + + // ─── Leading-hyphen escape (Lucene NOT-operator) ───────────────────────── + // `-` mid-token (HULY-51, bug-fix) is tolerated by ES, but a leading + // '-' is interpreted as the Lucene NOT operator. Field-targeted values + // with leading '-' need to be wrap+escaped; orphan tokens with leading + // '-' need at minimum the minus escaped so they stay literal. + it('wraps + escapes prefix values starting with a hyphen', () => { + expect(encodeSearch('title:-foo', 'all')).toBe('searchTitle:(\\-foo)') + expect(encodeSearch('id:-WORK-1', 'all')).toBe('identifier:(\\-WORK\\-1)') + }) + it('escapes a leading hyphen in orphan bare tokens after a prefix clause', () => { + expect(encodeSearch('title:meeting -cancelled', 'all')).toBe('searchTitle:meeting \\-cancelled') + }) + it('preserves mid-token hyphens in field values and orphan tokens', () => { + // Regression: identifier-style values keep their internal hyphens. + expect(encodeSearch('id:HULY-51', 'all')).toBe('identifier:HULY-51') + // Regression: orphan token with mid-hyphen passes through untouched. + expect(encodeSearch('title:meeting bug-fix', 'all')).toBe('searchTitle:meeting bug-fix') + }) + + // ─── H3: whitespace after the field colon ──────────────────────────────── + // `title: foo` (space after the colon) previously collapsed to an empty + // value; the bare `title:` token then had its colon escaped → 0 hits. The + // tokenizer now skips whitespace after the colon and routes the clause. + it('routes field-clauses with a space after the colon', () => { + expect(encodeSearch('title: foo', 'all')).toBe('searchTitle:foo') + expect(encodeSearch('title : foo', 'all')).toBe('searchTitle:foo') + expect(encodeSearch('id: HULY-1', 'all')).toBe('identifier:HULY-1') + }) + + // ─── M-VF1: mixed-case ES-native + user prefixes ───────────────────────── + // A field prefix typed in any casing must canonicalise to the exact ES + // field name; otherwise the case-sensitive tokenizer/adapter miss it and + // the colon gets escaped → 0 hits. + it('canonicalises mixed-case field prefixes', () => { + expect(encodeSearch('Identifier:HULY-1', 'all')).toBe('identifier:HULY-1') + expect(encodeSearch('SearchTitle:foo', 'all')).toBe('searchTitle:foo') + expect(encodeSearch('TITLE:foo', 'all')).toBe('searchTitle:foo') + }) + + // ─── M-VF2: Lucene boolean-operator + comparison chars ─────────────────── + // `&& || < > =` open boolean/range parsing in ES query_string; when we wrap + // a bare term into a scope clause they must be escaped so the value stays a + // literal and never throws query_string_parsing_exception. + it('escapes && || < > = inside scope-wrapped bare terms', () => { + expect(encodeSearch('a && b', 'title')).toBe('searchTitle:(a \\&\\& b)') + expect(encodeSearch('a || b', 'title')).toBe('searchTitle:(a \\|\\| b)') + expect(encodeSearch('x <= y', 'title')).toBe('searchTitle:(x \\<\\= y)') + }) + it('neutralises a dangling boolean operator at clause end', () => { + // Non-prefix scope-wrap: `foo AND` is grouped verbatim (AND is a bare word + // here, not routed through the strict tokenizer path). + expect(encodeSearch('foo AND', 'title')).toBe('searchTitle:(foo AND)') + // Prefix path: a trailing operator with no operand would throw, so the + // tokenizer escapes it into a literal term. + expect(encodeSearch('title:foo NOT', 'all')).toBe('searchTitle:foo \\NOT') + }) +}) diff --git a/packages/ui/src/components/Switcher.svelte b/packages/ui/src/components/Switcher.svelte index 00d2288e02a..f49ebf51015 100644 --- a/packages/ui/src/components/Switcher.svelte +++ b/packages/ui/src/components/Switcher.svelte @@ -5,7 +5,7 @@ // import { createEventDispatcher } from 'svelte' - import type { TabItem } from '../types' + import type { LabelAndProps, TabItem } from '../types' import SwitcherBase from './SwitcherBase.svelte' export let items: TabItem[] @@ -13,6 +13,11 @@ export let kind: 'nuance' | 'subtle' = 'nuance' export let name: string export let onlyIcons: boolean = false + /** When true, all items render disabled; click events are ignored. */ + export let disabled: boolean = false + /** Group-level tooltip; when set and `disabled` is true, every item + * shows this tooltip instead of its own. */ + export let tooltip: LabelAndProps | undefined = undefined const dispatch = createEventDispatcher() @@ -23,14 +28,16 @@ id={item.id} {name} {kind} + {disabled} checked={selected === item.id} icon={item.icon} color={item.color} title={onlyIcons ? undefined : item.label} label={onlyIcons ? undefined : item.labelIntl} labelParams={onlyIcons ? undefined : item.labelParams} - tooltip={item.tooltip ? { label: item.tooltip } : undefined} + tooltip={disabled && tooltip !== undefined ? tooltip : item.tooltip ? { label: item.tooltip } : undefined} on:change={() => { + if (disabled) return dispatch('select', item) if (item.action !== undefined) item.action() }} diff --git a/packages/ui/src/components/SwitcherBase.svelte b/packages/ui/src/components/SwitcherBase.svelte index 4a8d18b183f..60617bb05b3 100644 --- a/packages/ui/src/components/SwitcherBase.svelte +++ b/packages/ui/src/components/SwitcherBase.svelte @@ -21,12 +21,19 @@ export let name: string export let checked: boolean = false export let tooltip: LabelAndProps | undefined = undefined + export let disabled: boolean = false $: woTitle = title === undefined && label === undefined -