From cdb9587ac9e9ebdbe5d4e023f27c2129aeeaf5bd Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 30 Jun 2026 10:01:51 +0200 Subject: [PATCH 01/11] fix: interpret mjs as text/javascript --- apps/frontend/Dockerfile | 2 +- apps/frontend/default.conf | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 1490a35e44..351f043627 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -23,7 +23,7 @@ COPY apps/frontend/ /app/frontend RUN npm run build # Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx -FROM nginxinc/nginx-unprivileged:1.25-alpine +FROM nginxinc/nginx-unprivileged:alpine3.23 COPY --from=build-stage /app/frontend/build/ /usr/share/nginx/html COPY --from=build-stage /app/frontend/build-version.txt /usr/share/nginx/html diff --git a/apps/frontend/default.conf b/apps/frontend/default.conf index d193267366..fefe0925cf 100644 --- a/apps/frontend/default.conf +++ b/apps/frontend/default.conf @@ -1,6 +1,7 @@ server { listen 8080; - + root /usr/share/nginx/html; + gzip on; gzip_disable "msie6"; @@ -10,9 +11,17 @@ server { gzip_http_version 1.1; gzip_types text/plain application/javascript text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype; + # nginx's bundled mime.types has no mapping for .mjs, so ES module workers + # (e.g. pdf.worker.min.mjs) would be served as application/octet-stream and + # rejected by the browser's strict module MIME check. Set the type for .mjs + # only, without redefining the whole types map (a `types { }` block would + # REPLACE the inherited mime.types and break .html/.css serving). + location ~ \.mjs$ { + default_type text/javascript; + } + location / { - root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html =200; } -} \ No newline at end of file +} From 7d7bfa26e42582c7f19ef9b19b41d3e44fcc6486 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 30 Jun 2026 10:36:06 +0200 Subject: [PATCH 02/11] fix: add test --- .../cypress/e2e/proposalPdfTemplates.cy.ts | 20 +++++++++++++++++++ .../pdf/proposal/PdfTemplateEditorViewer.tsx | 11 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts b/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts index aa2367c4da..1c6b2832d1 100644 --- a/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts +++ b/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts @@ -218,6 +218,26 @@ context('Proposal PDF template tests', () => { cy.contains(newDescription).should('exist'); }); + it('User officer opens a PDF template editor → PDF preview renders without error', () => { + cy.login('officer'); + cy.visit('/'); + + cy.navigateToTemplatesSubmenu('PDF (Proposal)'); + + cy.contains(createdTemplateName) + .parent() + .find("[aria-label='Edit']") + .click(); + + cy.contains(createdTemplateName).should('exist'); + + cy.get('[data-cy="pdf-template-preview"] canvas', { + timeout: 60000, + }).should('be.visible'); + + cy.get('[data-cy="pdf-template-preview-error"]').should('not.exist'); + }); + it('User officer can clone PDF template', () => { cy.login('officer'); cy.visit('/'); diff --git a/apps/frontend/src/components/template/pdf/proposal/PdfTemplateEditorViewer.tsx b/apps/frontend/src/components/template/pdf/proposal/PdfTemplateEditorViewer.tsx index b1e6a7d06c..879c9cc878 100644 --- a/apps/frontend/src/components/template/pdf/proposal/PdfTemplateEditorViewer.tsx +++ b/apps/frontend/src/components/template/pdf/proposal/PdfTemplateEditorViewer.tsx @@ -42,8 +42,17 @@ const PDFViewer = ({ fileUrl }: { fileUrl: string }) => { height: '1000px', }} ref={setContainerRef} + data-cy="pdf-template-preview" > - + + Failed to load PDF file. + + } + > {Array.from(new Array(numPages), (_el, index) => ( Date: Tue, 30 Jun 2026 17:29:43 +0000 Subject: [PATCH 03/11] chore(deps): bump linkify-it and mailparser in /apps/backend Bumps [linkify-it](https://github.com/markdown-it/linkify-it) and [mailparser](https://github.com/nodemailer/mailparser). These dependencies needed to be updated together. Updates `linkify-it` from 5.0.0 to 5.0.1 - [Changelog](https://github.com/markdown-it/linkify-it/blob/master/CHANGELOG.md) - [Commits](https://github.com/markdown-it/linkify-it/compare/5.0.0...5.0.1) Updates `mailparser` from 3.6.7 to 3.9.12 - [Release notes](https://github.com/nodemailer/mailparser/releases) - [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodemailer/mailparser/compare/v3.6.7...v3.9.12) --- updated-dependencies: - dependency-name: linkify-it dependency-version: 5.0.1 dependency-type: indirect - dependency-name: mailparser dependency-version: 3.9.12 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- apps/backend/package-lock.json | 299 ++++++++++++++++++++++++++++----- 1 file changed, 257 insertions(+), 42 deletions(-) diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 87718be59f..a78ed249a1 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -7932,6 +7932,18 @@ "node": ">=10.0.0" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.13", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.13.tgz", + "integrity": "sha512-j40NeNlSAivqnKEjzZYsGP/bKWr2zwuyb8XOYsC3i0ZlfM5UnTYTa7aCRkE8rYQvVzE1dRjVYdvZ1Epi6x+h6w==", + "license": "(MIT OR EUPL-1.1+)", + "optional": true, + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.0", + "libqp": "2.1.1" + } + }, "node_modules/accepts": { "version": "1.3.8", "license": "MIT", @@ -9356,6 +9368,16 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "license": "MIT", @@ -9773,7 +9795,9 @@ } }, "node_modules/encoding-japanese": { - "version": "2.0.0", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", "license": "MIT", "optional": true, "engines": { @@ -13023,23 +13047,29 @@ } }, "node_modules/libbase64": { - "version": "1.2.1", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", "license": "MIT", "optional": true }, "node_modules/libmime": { - "version": "5.2.1", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.0.tgz", + "integrity": "sha512-MAWyU2qYtCsrZ6GClgukz2jx73NQrzA6ASo9qzThuTG+A7+H3lWQuBsjoy9lls+v6q/epAeBdEMJ3T0f4b+uRw==", "license": "MIT", "optional": true, "dependencies": { - "encoding-japanese": "2.0.0", - "iconv-lite": "0.6.3", - "libbase64": "1.2.1", - "libqp": "2.0.1" + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libqp": "2.1.1" } }, "node_modules/libmime/node_modules/iconv-lite": { - "version": "0.6.3", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "optional": true, "dependencies": { @@ -13047,6 +13077,10 @@ }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/libphonenumber-js": { @@ -13054,7 +13088,9 @@ "license": "MIT" }, "node_modules/libqp": { - "version": "2.0.1", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "license": "MIT", "optional": true }, @@ -13079,7 +13115,19 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "5.0.0", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "optional": true, "dependencies": { @@ -13193,52 +13241,157 @@ "license": "MIT" }, "node_modules/mailparser": { - "version": "3.6.7", + "version": "3.9.12", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.12.tgz", + "integrity": "sha512-kbT4xtkKEddonhDTjjWWVFJlI5axm0S9QuIAHs1ue3IEw+fNd9o4ytPKaG7p1LOE2aKifrWLjx/omOGLHz/9RQ==", "license": "MIT", "optional": true, "dependencies": { - "encoding-japanese": "2.0.0", + "@zone-eu/mailsplit": "5.4.13", + "encoding-japanese": "2.2.0", "he": "1.2.0", - "html-to-text": "9.0.5", - "iconv-lite": "0.6.3", - "libmime": "5.2.1", - "linkify-it": "5.0.0", - "mailsplit": "5.4.0", - "nodemailer": "6.9.9", - "tlds": "1.248.0" + "html-to-text": "10.0.0", + "iconv-lite": "0.7.2", + "libmime": "5.4.0", + "linkify-it": "5.0.1", + "nodemailer": "9.0.1", + "punycode.js": "2.3.1", + "tlds": "1.261.0" } }, - "node_modules/mailparser/node_modules/iconv-lite": { - "version": "0.6.3", + "node_modules/mailparser/node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", "license": "MIT", "optional": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, + "node_modules/mailparser/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "optional": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/mailparser/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "domelementtype": "^2.3.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/mailparser/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/mailparser/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/mailparser/node_modules/tlds": { - "version": "1.248.0", + "node_modules/mailparser/node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", "license": "MIT", "optional": true, - "bin": { - "tlds": "bin.js" + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" } }, - "node_modules/mailsplit": { - "version": "5.4.0", - "license": "(MIT OR EUPL-1.1+)", + "node_modules/mailparser/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", "optional": true, "dependencies": { - "libbase64": "1.2.1", - "libmime": "5.2.0", - "libqp": "2.0.1" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, - "node_modules/mailsplit/node_modules/iconv-lite": { - "version": "0.6.3", + "node_modules/mailparser/node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/mailparser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "optional": true, "dependencies": { @@ -13246,17 +13399,67 @@ }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/mailsplit/node_modules/libmime": { - "version": "5.2.0", + "node_modules/mailparser/node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/mailparser/node_modules/nodemailer": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "license": "MIT-0", + "optional": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/mailparser/node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/mailparser/node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/mailparser/node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", "license": "MIT", "optional": true, "dependencies": { - "encoding-japanese": "2.0.0", - "iconv-lite": "0.6.3", - "libbase64": "1.2.1", - "libqp": "2.0.1" + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" } }, "node_modules/make-dir": { @@ -14835,6 +15038,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.0.4", "dev": true, @@ -16630,7 +16843,9 @@ } }, "node_modules/uc.micro": { - "version": "2.0.0", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT", "optional": true }, From 8f06677fa3a7db673809510a79398ab038ebe4c2 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Thu, 2 Jul 2026 17:54:47 +0200 Subject: [PATCH 04/11] fix: fix the test mock data so that the template can render in factorh --- .../cypress/e2e/proposalPdfTemplates.cy.ts | 534 +++++++++++++++++- apps/e2e/cypress/support/template.ts | 13 + apps/e2e/cypress/types/template.d.ts | 15 + 3 files changed, 561 insertions(+), 1 deletion(-) diff --git a/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts b/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts index 1c6b2832d1..4a9884bfd6 100644 --- a/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts +++ b/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts @@ -98,6 +98,538 @@ context('Proposal PDF template tests', () => { createdTemplateName = result.createTemplate.name; } }); + + cy.updateProposalPdfTemplate({ + proposalPdfTemplateId: 1, + dummyData: `{ + "data": { + "proposal": { + "primaryKey": 4, + "title": "New Proposal", + "abstract": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum", + "proposerId": 1, + "statusId": 1, + "created": "2023-10-18T11:38:15.429Z", + "updated": "2023-10-18T11:39:02.691Z", + "proposalId": "268490", + "callId": 1, + "questionaryId": 4, + "notified": false, + "submitted": true, + "referenceNumberSequence": 3, + "managementTimeAllocation": null, + "managementDecisionSubmitted": false + }, + "principalInvestigator": { + "id": 1, + "firstname": "Carl", + "lastname": "Carlsson", + "preferredname": "Carl", + "institution": "Other", + "institutionId": 1, + "position": "Strategist", + "created": "2023-10-10T13:32:18.172Z", + "placeholder": false, + "email": "Javon4@hotmail.com" + }, + "coProposers": [ + { + "id": 4, + "firstname": "Benjamin", + "lastname": "Beckley", + "preferredname": "Benjamin", + "institution": "Other", + "institutionId": 1, + "position": "Management", + "created": "2023-10-18T11:38:15.429Z", + "placeholder": false, + "email": "ben@inbox.com" + } + ], + "questionarySteps": [ + { + "questionaryId": 4, + "topic": { + "id": 41, + "title": "Samples & Info", + "templateId": 39, + "sortOrder": 1, + "isEnabled": true + }, + "isCompleted": true, + "fields": [ + { + "question": { + "categoryId": 1, + "id": "date_1661426719613", + "naturalKey": "date_1661426719613", + "dataType": "DATE", + "question": "When do you want to come?", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "includeTime": false, + "maxDate": null, + "minDate": null, + "defaultDate": null + } + }, + "topicId": 41, + "sortOrder": 0, + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "includeTime": false, + "maxDate": null, + "minDate": null, + "defaultDate": null + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 4, + "value": null + }, + { + "question": { + "categoryId": 1, + "id": "sample_declaration_1666269436134", + "naturalKey": "sample_declaration_1666269436134", + "dataType": "SAMPLE_DECLARATION", + "question": "Samples", + "config": { + "addEntryButtonLabel": "Add", + "templateCategory": "SAMPLE_DECLARATION", + "templateId": 36, + "esiTemplateId": null, + "small_label": "", + "required": false, + "maxEntries": null, + "minEntries": null + } + }, + "topicId": 41, + "sortOrder": 1, + "config": { + "addEntryButtonLabel": "Add", + "templateCategory": "SAMPLE_DECLARATION", + "templateId": 36, + "esiTemplateId": null, + "small_label": "", + "required": false, + "maxEntries": null, + "minEntries": null + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": null, + "value": [] + }, + { + "question": { + "categoryId": 1, + "id": "selection_from_options_1605784613207", + "naturalKey": "selection_from_options_instrument", + "dataType": "SELECTION_FROM_OPTIONS", + "question": "Select an instrument", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "options": ["LoKi", "MX"], + "isMultipleSelect": false + } + }, + "topicId": 41, + "sortOrder": 2, + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "options": ["LoKi", "MX", "CRYO-EM"], + "isMultipleSelect": true + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 5, + "value": ["MX"] + }, + { + "question": { + "categoryId": 1, + "id": "generic_template_1674814357714", + "naturalKey": "generic_template_1674814357714", + "dataType": "GENERIC_TEMPLATE", + "question": "MX", + "config": { + "addEntryButtonLabel": "Add", + "copyButtonLabel": "", + "canCopy": false, + "isMultipleCopySelect": false, + "isCompleteOnCopy": false, + "templateCategory": "GENERIC_TEMPLATE", + "templateId": 38, + "small_label": "", + "required": false, + "maxEntries": "1", + "minEntries": "1" + } + }, + "topicId": 41, + "sortOrder": 5, + "config": { + "addEntryButtonLabel": "Add", + "copyButtonLabel": "", + "canCopy": false, + "isMultipleCopySelect": false, + "isCompleteOnCopy": false, + "templateCategory": "GENERIC_TEMPLATE", + "templateId": 38, + "small_label": "", + "required": false, + "maxEntries": 1, + "minEntries": 1 + }, + "dependencies": [ + { + "questionId": "generic_template_1674814357714", + "dependencyId": "selection_from_options_1605784613207", + "dependencyNaturalKey": "selection_from_options_instrument", + "condition": { + "params": "MX", + "condition": "eq" + } + } + ], + "dependenciesOperator": "AND", + "answerId": null, + "value": [ + { + "id": 1, + "title": "template 1", + "creatorId": 1, + "proposalPk": 4, + "questionaryId": 5, + "questionId": "generic_template_1674814357714", + "created": "2023-10-18T11:38:39.861Z" + } + ] + }, + { + "question": { + "categoryId": 1, + "id": "boolean_1674812264143", + "naturalKey": "boolean_1674812264143", + "dataType": "BOOLEAN", + "question": "Do you want sample environment for MX", + "config": { + "small_label": "", + "required": false, + "tooltip": "" + } + }, + "topicId": 41, + "sortOrder": 6, + "config": { + "small_label": "", + "required": false, + "tooltip": "" + }, + "dependencies": [ + { + "questionId": "boolean_1674812264143", + "dependencyId": "selection_from_options_1605784613207", + "dependencyNaturalKey": "selection_from_options_instrument", + "condition": { + "params": "MX", + "condition": "eq" + } + } + ], + "dependenciesOperator": "AND", + "answerId": 6, + "value": true + }, + { + "question": { + "categoryId": 1, + "id": "embellishment_1636710963252", + "naturalKey": "embellishment_1636710963252", + "dataType": "EMBELLISHMENT", + "question": "New question", + "config": { + "html": "

Users can also submit Expression of Interest in the following Crystallisation services:

\\n
    \\n
  • Support for large protein crystal growth of either protiated or deuterated proteins (incl. sitting drop vapour diffusion, dialysis, batch, optimization, targeted screening, testing with X-rays)
  • \\n
", + "plain": "Crystallisation text", + "omitFromPdf": true + } + }, + "topicId": 41, + "sortOrder": 7, + "config": { + "html": "

Users can also submit Expression of Interest in the following Crystallisation services:

\\n
    \\n
  • Support for large protein crystal growth of either protiated or deuterated proteins (incl. sitting drop vapour diffusion, dialysis, batch, optimization, targeted screening, testing with X-rays)
  • \\n
", + "plain": "Crystallisation text", + "omitFromPdf": true + }, + "dependencies": [ + { + "questionId": "embellishment_1636710963252", + "dependencyId": "selection_from_options_1605784613207", + "dependencyNaturalKey": "selection_from_options_instrument", + "condition": { + "params": "MX", + "condition": "eq" + } + } + ], + "dependenciesOperator": "AND", + "answerId": null, + "value": null + } + ] + }, + { + "questionaryId": 4, + "topic": { + "id": 42, + "title": "Sample environment", + "templateId": 39, + "sortOrder": 2, + "isEnabled": true + }, + "isCompleted": true, + "fields": [ + { + "question": { + "categoryId": 1, + "id": "boolean_1674821942563", + "naturalKey": "boolean_1674821942563", + "dataType": "BOOLEAN", + "question": "Do you need lab-support?", + "config": { + "small_label": "", + "required": false, + "tooltip": "" + } + }, + "topicId": 42, + "sortOrder": 0, + "config": { + "small_label": "", + "required": false, + "tooltip": "" + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 7, + "value": true + }, + { + "question": { + "categoryId": 1, + "id": "final_delivery_date_motivation", + "naturalKey": "final_delivery_date_motivation", + "dataType": "TEXT_INPUT", + "question": "Please give a brief explanation for the chosen date", + "config": { + "required": true, + "small_label": "", + "tooltip": "", + "htmlQuestion": "", + "isHtmlQuestion": false, + "min": "1", + "max": 500, + "multiline": true, + "placeholder": "(maximum 500 characters)", + "isCounterHidden": false + } + }, + "topicId": 42, + "sortOrder": 1, + "config": { + "required": true, + "small_label": "", + "tooltip": "", + "htmlQuestion": "", + "isHtmlQuestion": false, + "min": 1, + "max": 500, + "multiline": true, + "placeholder": "(maximum 500 characters)", + "isCounterHidden": false + }, + "dependencies": [ + { + "questionId": "final_delivery_date_motivation", + "dependencyId": "boolean_1674821942563", + "dependencyNaturalKey": "boolean_1674821942563", + "condition": { + "params": true, + "condition": "eq" + } + } + ], + "dependenciesOperator": "AND", + "answerId": 8, + "value": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, an" + } + ] + } + ], + "attachments": [], + "samples": [], + "genericTemplates": [ + { + "genericTemplate": { + "id": 1, + "title": "template 1", + "creatorId": 1, + "proposalPk": 4, + "questionaryId": 5, + "questionId": "generic_template_1674814357714", + "created": "2023-10-18T11:38:39.861Z" + }, + "genericTemplateQuestionaryFields": [ + { + "question": { + "categoryId": 7, + "id": "generic_template_basis", + "naturalKey": "generic_template_basis", + "dataType": "GENERIC_TEMPLATE_BASIS", + "question": "Template basic information", + "config": { + "titlePlaceholder": "Title", + "questionLabel": "", + "tooltip": "", + "required": false, + "small_label": "" + } + }, + "topicId": 39, + "sortOrder": 0, + "config": { + "titlePlaceholder": "Title", + "questionLabel": "", + "tooltip": "", + "required": false, + "small_label": "" + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": null, + "value": null + }, + { + "question": { + "categoryId": 7, + "id": "boolean_1674814267658", + "naturalKey": "boolean_1674814267658", + "dataType": "BOOLEAN", + "question": "New question", + "config": { + "small_label": "", + "required": false, + "tooltip": "" + } + }, + "topicId": 39, + "sortOrder": 1, + "config": { + "small_label": "", + "required": false, + "tooltip": "" + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 1, + "value": true + }, + { + "question": { + "categoryId": 7, + "id": "date_1674814304449", + "naturalKey": "date_1674814304449", + "dataType": "DATE", + "question": "New question", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "includeTime": false, + "maxDate": null, + "minDate": null, + "defaultDate": null + } + }, + "topicId": 39, + "sortOrder": 2, + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "includeTime": false, + "maxDate": null, + "minDate": null, + "defaultDate": null + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 2, + "value": "2023-10-19T22:00:00.000Z" + }, + { + "question": { + "categoryId": 7, + "id": "text_input_1674814274952", + "naturalKey": "text_input_1674814274952", + "dataType": "TEXT_INPUT", + "question": "New question", + "config": { + "required": false, + "small_label": "", + "tooltip": "", + "htmlQuestion": "", + "isHtmlQuestion": false, + "min": null, + "max": null, + "multiline": false, + "placeholder": "", + "isCounterHidden": false + } + }, + "topicId": 39, + "sortOrder": 3, + "config": { + "required": false, + "small_label": "", + "tooltip": "", + "htmlQuestion": "", + "isHtmlQuestion": false, + "min": null, + "max": null, + "multiline": false, + "placeholder": "", + "isCounterHidden": false + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 3, + "value": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum" + } + ] + } + ] + }, + "userRole": { + "id": 2, + "shortCode": "user_officer", + "title": "User Officer" + } +} +`, + }); }); it('User officer can modify PDF template', () => { @@ -218,7 +750,7 @@ context('Proposal PDF template tests', () => { cy.contains(newDescription).should('exist'); }); - it('User officer opens a PDF template editor → PDF preview renders without error', () => { + it.only('User officer opens a PDF template editor → PDF preview renders without error', () => { cy.login('officer'); cy.visit('/'); diff --git a/apps/e2e/cypress/support/template.ts b/apps/e2e/cypress/support/template.ts index b36fe60fc0..324e909bc0 100644 --- a/apps/e2e/cypress/support/template.ts +++ b/apps/e2e/cypress/support/template.ts @@ -20,6 +20,8 @@ import { UpdateQuestionMutationVariables, UpdateQuestionTemplateRelationSettingsMutation, UpdateQuestionTemplateRelationSettingsMutationVariables, + UpdateProposalPdfTemplateMutation, + UpdateProposalPdfTemplateMutationVariables, UpdateTopicMutation, UpdateTopicMutationVariables, } from '@user-office-software-libs/shared-types'; @@ -73,6 +75,15 @@ const setActiveTemplate = ( return cy.wrap(request); }; +const updateProposalPdfTemplate = ( + updateProposalPdfTemplateInput: UpdateProposalPdfTemplateMutationVariables +): Cypress.Chainable => { + const api = getE2EApi(); + const request = api.updateProposalPdfTemplate(updateProposalPdfTemplateInput); + + return cy.wrap(request); +}; + const typeToMenuTitle = new Map(); typeToMenuTitle.set('proposal', 'Proposal'); typeToMenuTitle.set('sample', 'Sample declaration'); @@ -810,3 +821,5 @@ Cypress.Commands.add( ); Cypress.Commands.add('setActiveTemplate', setActiveTemplate); + +Cypress.Commands.add('updateProposalPdfTemplate', updateProposalPdfTemplate); diff --git a/apps/e2e/cypress/types/template.d.ts b/apps/e2e/cypress/types/template.d.ts index 5e16ceb86a..e1b461ec46 100644 --- a/apps/e2e/cypress/types/template.d.ts +++ b/apps/e2e/cypress/types/template.d.ts @@ -21,6 +21,8 @@ import { UpdateQuestionTemplateRelationSettingsMutationVariables, UpdateQuestionTemplateRelationSettingsMutation, SetActiveTemplateMutationVariables, + UpdateProposalPdfTemplateMutationVariables, + UpdateProposalPdfTemplateMutation, } from '@user-office-software-libs/shared-types'; declare global { @@ -315,6 +317,7 @@ declare global { createTemplate: ( createTemplateInput: CreateTemplateMutationVariables ) => Cypress.Chainable; + /** * Clone template * @@ -397,6 +400,18 @@ declare global { setActiveTemplate: ( setActiveTemplateInput: SetActiveTemplateMutationVariables ) => Cypress.Chainable; + + /** + * Updates proposal PDF template + * + * @returns {typeof updateProposalPdfTemplate} + * @memberof Chainable + * @example + * cy.updateProposalPdfTemplate(updateProposalPdfTemplateInput: UpdateProposalPdfTemplateMutationVariables) + */ + updateProposalPdfTemplate: ( + updateProposalPdfTemplateInput: UpdateProposalPdfTemplateMutationVariables + ) => Cypress.Chainable; } } } From 46a26336f50eba7a543cc812b669a2f489689ec2 Mon Sep 17 00:00:00 2001 From: shivoomiess <292110273+shivoomiess@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:26:38 +0200 Subject: [PATCH 05/11] fix: invite chips overflow out of invites-chips box (#1595) --- .../components/common/UserManagementTable.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index 154b71ca9c..c5c8e61003 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -157,10 +157,9 @@ const UserManagementTable = ({ @@ -170,21 +169,25 @@ const UserManagementTable = ({ color: 'grey', paddingRight: '10px', display: 'inline-block', + whiteSpace: 'nowrap', + mt: '4px', }} > Invited: - {invites.map((invite) => ( - : } - size="small" - label={invite.email} - key={invite.email} - onDelete={() => handleDeleteInvite(invite)} - /> - ))} + + {invites.map((invite) => ( + : } + size="small" + label={invite.email} + key={invite.email} + onDelete={() => handleDeleteInvite(invite)} + /> + ))} + )} Date: Sat, 4 Jul 2026 08:02:38 +0000 Subject: [PATCH 06/11] chore(deps-dev): bump lint-staged from 17.0.7 to 17.0.8 Bumps [lint-staged](https://github.com/lint-staged/lint-staged) from 17.0.7 to 17.0.8. - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) --- updated-dependencies: - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c051ace2a..79aa12258a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -503,9 +503,9 @@ } }, "node_modules/lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", "dev": true, "dependencies": { "listr2": "^10.2.1", @@ -1275,9 +1275,9 @@ } }, "lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", "dev": true, "requires": { "listr2": "^10.2.1", From 41a2eac19ea9a7fbab5874fce4b3ec70babf13b3 Mon Sep 17 00:00:00 2001 From: shivoomiess <292110273+shivoomiess@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:26:23 +0200 Subject: [PATCH 07/11] fix: wrong Modal Header when adding users (#1594) --- apps/frontend/src/components/common/UserManagementTable.tsx | 5 ++++- apps/frontend/src/components/proposal/CoProposers.tsx | 1 + .../src/components/proposal/DataAccessUsersModal.tsx | 1 + .../src/components/proposalBooking/CreateUpdateVisit.tsx | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index c5c8e61003..7eddfdb066 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -27,6 +27,8 @@ export type UserManagementTableProps = { title: string; addButtonLabel?: string; addButtonTooltip?: string; + /** Header shown at the top of the invite/selector modal */ + addModalTitle?: string; /** Disable the add button */ disabled?: boolean; /** Custom actions to be passed to PeopleTable */ @@ -45,6 +47,7 @@ const UserManagementTable = ({ title, addButtonLabel = 'Add', addButtonTooltip = 'Add a participant', + addModalTitle, disabled = false, onUserAction, excludeUserIds = [], @@ -78,7 +81,7 @@ const UserManagementTable = ({ const InviteComponent = ( setOpen(false)} onAddParticipants={handleAddParticipants} excludeUserIds={[...users.map((user) => user.id), ...excludeUserIds]} diff --git a/apps/frontend/src/components/proposal/CoProposers.tsx b/apps/frontend/src/components/proposal/CoProposers.tsx index bcd2f38597..f7ab1cfe3d 100644 --- a/apps/frontend/src/components/proposal/CoProposers.tsx +++ b/apps/frontend/src/components/proposal/CoProposers.tsx @@ -41,6 +41,7 @@ const CoProposers = ({ {...props} title="Co-Proposers" addButtonTooltip="Add a co-proposer" + addModalTitle="Add co-proposers" onUserAction={handleUserAction} excludeUserIds={proposer ? [proposer.id] : []} // QuickFix for material table changing immutable state diff --git a/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx b/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx index 60df81b840..b5bef8b796 100644 --- a/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx +++ b/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx @@ -87,6 +87,7 @@ const DataAccessUsersModal = ({ setInvites={() => {}} title="Data access users" addButtonLabel="Add Data Access User" + addModalTitle="Add Data Access User" excludeUserIds={excludeUserIds} /> diff --git a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx index 38f70bd478..66c2e0c449 100644 --- a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx +++ b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx @@ -93,6 +93,7 @@ function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { { From 75133dbd04b260da5eb29b79b357e199d52085bf Mon Sep 17 00:00:00 2001 From: GrantDLS Date: Mon, 6 Jul 2026 11:00:46 +0100 Subject: [PATCH 08/11] feat: diamond email handler changes (#1617) --- ...proposalCoProposerInvitesUpdatedHandler.ts | 3 +- .../email/DLS/proposalSubmittedHandler.ts | 101 +++++++++++++----- 2 files changed, 77 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts b/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts index fb13ea2edf..d3178aa09f 100644 --- a/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts +++ b/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts @@ -59,7 +59,8 @@ export async function proposalCoProposerInvitesUpdatedHandler( template: emailTemplate.id.toString(), }, substitution_data: { - sender: inviter.preferredname + ' ' + inviter.lastname, + sender: + (inviter.preferredname || inviter.firstname) + ' ' + inviter.lastname, redeem_code: invite.code, uos_instance: process.env.BASE_URL, }, diff --git a/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts b/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts index 414acb4222..b3ed457832 100644 --- a/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts +++ b/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts @@ -10,9 +10,39 @@ import { QuestionaryDataSource } from '../../../datasources/QuestionaryDataSourc import { UserDataSource } from '../../../datasources/UserDataSource'; import { ApplicationEvent } from '../../../events/applicationEvents'; import { Event } from '../../../events/event.enum'; +import { getTopicActiveAnswers } from '../../../models/ProposalModelFunctions'; +import { Answer, QuestionaryStep } from '../../../models/Questionary'; +import { DataType } from '../../../models/Template'; import { MailService } from '../../MailService/MailService'; import { EmailTemplateId } from '../emailTemplateId'; +type InstrumentPickerAnswerValue = { + instrumentId: number | string; + timeRequested?: number | string | null; +}; + +const getInstrumentPickerAnswers = ( + questionarySteps: QuestionaryStep[] +): Answer[] => + questionarySteps + .flatMap((step) => getTopicActiveAnswers(questionarySteps, step.topic.id)) + .filter( + (answer) => answer.question.dataType === DataType.INSTRUMENT_PICKER + ); + +const getInstrumentPickerAnswerValues = ( + value: unknown +): InstrumentPickerAnswerValue[] => { + const values = Array.isArray(value) ? value : value ? [value] : []; + + return values.filter( + (instrumentAnswer): instrumentAnswer is InstrumentPickerAnswerValue => + typeof instrumentAnswer === 'object' && + instrumentAnswer !== null && + 'instrumentId' in instrumentAnswer + ); +}; + export async function proposalSubmittedHandler(event: ApplicationEvent) { if (event.type != Event.PROPOSAL_SUBMITTED) return; @@ -54,28 +84,49 @@ export async function proposalSubmittedHandler(event: ApplicationEvent) { event.proposal.callId ); - const instruments = await instrumentSource.getInstrumentsByProposalPk( - event.proposal.primaryKey + const questionarySteps = await questionaryDataSource.getQuestionarySteps( + event.proposal.questionaryId ); - // Postgres implementation doesn't match interface - impliementation wants questionaryId, not proposalId - const answer = await questionaryDataSource.getAnswer( - event.proposal.questionaryId, - 'instrument_picker' + const instrumentPickerAnswers = getInstrumentPickerAnswers(questionarySteps); + const instrumentPickerAnswerValues = instrumentPickerAnswers.flatMap( + (answer) => getInstrumentPickerAnswerValues(answer.value) + ); + const instrumentIds = Array.from( + new Set( + instrumentPickerAnswerValues + .map((instrumentAnswer) => Number(instrumentAnswer.instrumentId)) + .filter((instrumentId) => Number.isFinite(instrumentId)) + ) ); - ( - answer?.answer as { - value: { instrumentId: number; timeRequested: number }[]; - } - )?.value.forEach((instrumentAnswer: any) => { - const instrument = instruments.find( - (inst) => inst.id === Number(instrumentAnswer.instrumentId) - ); - if (instrument) { - instrument.managementTimeAllocation = instrumentAnswer.timeRequested || 0; - } - }); + const instruments = await instrumentSource.getInstrumentsByIds(instrumentIds); + const requested = instrumentPickerAnswerValues + .map((instrumentAnswer) => { + const requestedTime = Number(instrumentAnswer.timeRequested ?? 0); + const formattedRequestedTime = Number.isFinite(requestedTime) + ? requestedTime + : 0; + const instrument = instruments.find( + (inst) => inst.id === Number(instrumentAnswer.instrumentId) + ); + if (!instrument) { + return null; + } + + const timeUnit = `${call.allocationTimeUnit}${ + formattedRequestedTime > 1 || formattedRequestedTime === 0 ? 's' : '' + }`; + + return [ + `${instrument.name}:`, + instrument.description, + formattedRequestedTime, + timeUnit, + ].join(' '); + }) + .filter((request): request is string => request !== null) + .join(', '); const shortDateFormat = new Intl.DateTimeFormat('en-GB', { month: 'short', @@ -115,19 +166,17 @@ export async function proposalSubmittedHandler(event: ApplicationEvent) { submittedOn: event.proposal.submittedDate!.toLocaleString(), accessRoute: workflow?.name || 'N/A', principalInvestigator: - principalInvestigator.preferredname + + (principalInvestigator.preferredname || + principalInvestigator.firstname) + ' ' + principalInvestigator.lastname, establishment: principalInvestigator.institution, alternativeContacts: '', coinvestigators: participants.map( - (partipant) => `${partipant.preferredname} ${partipant.lastname} ` + (partipant) => + `${partipant.preferredname || partipant.firstname} ${partipant.lastname} ` ), - requested: instruments - .map((instrument) => { - return `${instrument.name}: ${instrument.description} ${instrument.managementTimeAllocation} ${call.allocationTimeUnit}${instrument.managementTimeAllocation > 1 ? 's' : ''}`; - }) - .join(', '), + requested, }, allocationPeriod: allocationPeriod, deadline: longDateFormat.format(call.endCall), @@ -153,7 +202,7 @@ export async function proposalSubmittedHandler(event: ApplicationEvent) { ...options, substitution_data: { ...(options.substitution_data as any), - name: participant.preferredname, + name: participant.preferredname || participant.firstname, }, recipients: [ { From e56210d89b3416bcc2ea9849f0ee0d8ef0794e48 Mon Sep 17 00:00:00 2001 From: zacharyjhankin Date: Mon, 6 Jul 2026 12:29:45 +0100 Subject: [PATCH 09/11] feat: add baseURL option for dynamic http question (#1541) --- .../db_patches/0209_addBaseulrtodynamiccs.sql | 16 ++ .../db_seeds/0005_ProposalQuestions.sql | 4 +- apps/backend/example.development.env | 2 +- .../datasources/mockups/TemplateDataSource.ts | 17 ++ .../questionTypes/DynamicMultipleChoice.ts | 1 + .../src/queries/TemplateQueries.spec.ts | 28 ++++ apps/backend/src/queries/TemplateQueries.ts | 13 +- .../resolvers/queries/ServerConfigQuery.ts | 11 ++ .../src/resolvers/types/FieldConfig.ts | 3 + .../src/resolvers/types/ServerConfig.ts | 7 + apps/e2e/cypress/e2e/templateContext.ts | 1 + apps/e2e/cypress/e2e/templatesBasic.cy.ts | 5 + .../e2e/cypress/fixtures/template_export.json | 6 + apps/e2e/cypress/types/template.d.ts | 1 + .../QuestionDynamicMultipleChoiceForm.tsx | 140 +++++++++++++++-- ...plateRelationDynamicMultipleChoiceForm.tsx | 146 ++++++++++++++++-- .../src/graphql/admin/getServerConfig.graphql | 5 + .../template/fragment.fieldConfig.graphql | 1 + docker-compose.yml | 2 +- 19 files changed, 376 insertions(+), 33 deletions(-) create mode 100644 apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql create mode 100644 apps/backend/src/resolvers/queries/ServerConfigQuery.ts create mode 100644 apps/backend/src/resolvers/types/ServerConfig.ts create mode 100644 apps/frontend/src/graphql/admin/getServerConfig.graphql diff --git a/apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql b/apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql new file mode 100644 index 0000000000..92d71c1933 --- /dev/null +++ b/apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql @@ -0,0 +1,16 @@ +DO +$$ +BEGIN + IF register_patch('addBaseulrtodynamiccs.sql', 'Zachary Hankin', 'Adds an option to the dynamic HTTP question type allowing use of base domain', '2026-5-21') THEN + UPDATE questions + SET default_config = jsonb_set( + default_config::jsonb, + '{useBaseDomain}', + 'false'::jsonb, + true + ) + WHERE data_type = 'DYNAMIC_MULTIPLE_CHOICE'; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql b/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql index ac01b60896..00d33f8448 100644 --- a/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql +++ b/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql @@ -376,7 +376,7 @@ VALUES ( 'dynamic_multiple_choice_question', 'DYNAMIC_MULTIPLE_CHOICE', 'Dynamic multiple choice question from seeds', - '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[], "readPermissions":[]}', + '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[], "readPermissions":[], "useBaseDomain":false}', '2023-02-08 10:23:10.285415+00', '2023-02-08 10:23:10.285415+00', 'dynamic_multiple_choice_question', @@ -389,7 +389,7 @@ INSERT INTO templates_has_questions( VALUES ( 'dynamic_multiple_choice_question', - proposal_template_id_var, proposal_topic_id_var, 10, '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[],"readPermissions":[]}' + proposal_template_id_var, proposal_topic_id_var, 10, '{"useBaseDomain":false, "variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[],"readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer diff --git a/apps/backend/example.development.env b/apps/backend/example.development.env index dde1b439b0..60b944e96f 100644 --- a/apps/backend/example.development.env +++ b/apps/backend/example.development.env @@ -44,7 +44,7 @@ NODE_ENV=development # DEPENDENCY_CONFIG= PING_PUBLIC_CRT=dummypingsecret DATABASE_URL=postgres://duouser:duopassword@127.0.0.1:5432/duo -BASE_URL=localhost:3000 +BASE_URL=http://localhost:3000 JWT_TOKEN_LIFE=7d JWT_SECRET=qMyLZALzs229ybdQXNyzYRdju7X784TH # SPARKPOST_TOKEN=insertokenhere diff --git a/apps/backend/src/datasources/mockups/TemplateDataSource.ts b/apps/backend/src/datasources/mockups/TemplateDataSource.ts index c65f132d48..025ff05186 100644 --- a/apps/backend/src/datasources/mockups/TemplateDataSource.ts +++ b/apps/backend/src/datasources/mockups/TemplateDataSource.ts @@ -126,6 +126,20 @@ const dummyTemplateStepsFactory = () => { config: { url: '', jsonPath: '', + useBaseDomain: false, + } as DynamicMultipleChoiceConfig, + }), + }); + + const dmcQuestionWithBaseDomain = dummyQuestionTemplateRelationFactory({ + question: dummyQuestionFactory({ + id: 'dmcQuestionWithBaseDomain', + naturalKey: 'dmcQuestionWithBaseDomain', + dataType: DataType.DYNAMIC_MULTIPLE_CHOICE, + config: { + url: 'getListOfCountries', + useBaseDomain: true, + jsonPath: '', } as DynamicMultipleChoiceConfig, }), }); @@ -138,6 +152,7 @@ const dummyTemplateStepsFactory = () => { config: { url: 'api-url', jsonPath: '', + useBaseDomain: false, apiCallRequestHeaders: [ { name: 'header1', @@ -160,6 +175,7 @@ const dummyTemplateStepsFactory = () => { config: { url: 'api-url', jsonPath: '$..option', + useBaseDomain: false, } as DynamicMultipleChoiceConfig, }), }); @@ -175,6 +191,7 @@ const dummyTemplateStepsFactory = () => { dmcQuestionEmptyUrl, dmcQuestionEmptyJsonPath, dmcQuestionWithUrlAndJsonPath, + dmcQuestionWithBaseDomain, ]), ]; }; diff --git a/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts b/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts index 4d4ed3d602..f51d72f950 100644 --- a/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts +++ b/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts @@ -29,6 +29,7 @@ export const dynamicMultipleChoiceDefinition: Question { expect(options).toEqual(['option1', 'option2']); }); + it('Should return options if selected useBaseDomain', async () => { + process.env = { + ...process.env, + BASE_URL: 'http://mocked.example.com', + }; + + jest + .spyOn(global, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url === 'http://mocked.example.com/getListOfCountries') { + return Promise.resolve({ + json: () => Promise.resolve(['option1', 'option2']), + ok: true, + } as Response); + } else { + return Promise.reject(new Error('Unknown URL')); + } + }); + + const options = await templateQueries.getDynamicMultipleChoiceOptions( + dummyUserWithRole, + 'dmcQuestionWithBaseDomain' + ); + + expect(options).toEqual(['option1', 'option2']); + }); it('should use the question template relation if a template ID is supplied', async () => { const templateDataSource = container.resolve( Tokens.TemplateDataSource diff --git a/apps/backend/src/queries/TemplateQueries.ts b/apps/backend/src/queries/TemplateQueries.ts index b054ee4da5..d93abdfd7f 100644 --- a/apps/backend/src/queries/TemplateQueries.ts +++ b/apps/backend/src/queries/TemplateQueries.ts @@ -104,10 +104,11 @@ export default class TemplateQueries { if (!question) return []; const config = question.config as DynamicMultipleChoiceConfig; - if (config.url === '') return []; + const dynamicURL = constructDynamicURL(config.url, config.useBaseDomain); + if (dynamicURL === '') return []; try { - const response = await fetch(config.url, { + const response = await fetch(dynamicURL, { headers: config.apiCallRequestHeaders?.reduce( (acc, header) => ({ ...acc, @@ -166,3 +167,11 @@ export default class TemplateQueries { return this.dataSource.getQuestion(questionId); } } + +function constructDynamicURL(url: string, useBaseURL: boolean): string { + if (useBaseURL) { + return `${process.env.BASE_URL}/${url}`; + } + + return url; +} diff --git a/apps/backend/src/resolvers/queries/ServerConfigQuery.ts b/apps/backend/src/resolvers/queries/ServerConfigQuery.ts new file mode 100644 index 0000000000..8b83ba2fc0 --- /dev/null +++ b/apps/backend/src/resolvers/queries/ServerConfigQuery.ts @@ -0,0 +1,11 @@ +import { Query, Resolver } from 'type-graphql'; + +import { ServerConfig } from '../types/ServerConfig'; + +@Resolver() +export class ServerConfigQuery { + @Query(() => ServerConfig, { nullable: false }) + ServerConfig() { + return { baseURL: process.env.BASE_URL }; + } +} diff --git a/apps/backend/src/resolvers/types/FieldConfig.ts b/apps/backend/src/resolvers/types/FieldConfig.ts index 044b609d38..dd845a0bbe 100644 --- a/apps/backend/src/resolvers/types/FieldConfig.ts +++ b/apps/backend/src/resolvers/types/FieldConfig.ts @@ -156,6 +156,9 @@ export class DynamicMultipleChoiceConfig extends ConfigBase { @Field(() => String) jsonPath: string; + @Field(() => Boolean) + useBaseDomain: boolean; + @Field(() => Boolean) isMultipleSelect: boolean; diff --git a/apps/backend/src/resolvers/types/ServerConfig.ts b/apps/backend/src/resolvers/types/ServerConfig.ts new file mode 100644 index 0000000000..2ea09058e9 --- /dev/null +++ b/apps/backend/src/resolvers/types/ServerConfig.ts @@ -0,0 +1,7 @@ +import { ObjectType, Field } from 'type-graphql'; + +@ObjectType() +export class ServerConfig { + @Field(() => String) + public baseURL: string; +} diff --git a/apps/e2e/cypress/e2e/templateContext.ts b/apps/e2e/cypress/e2e/templateContext.ts index 95f08ec547..2095f43550 100644 --- a/apps/e2e/cypress/e2e/templateContext.ts +++ b/apps/e2e/cypress/e2e/templateContext.ts @@ -66,6 +66,7 @@ const dynamicMultipleChoiceQuestion = { title: faker.lorem.words(2), url: 'http://localhost:9000', jsonPath: '$.*.item', + useBaseDomain: false, answers: { arrayString: [ faker.lorem.words(3), diff --git a/apps/e2e/cypress/e2e/templatesBasic.cy.ts b/apps/e2e/cypress/e2e/templatesBasic.cy.ts index f8e65633ba..ae9c77d252 100644 --- a/apps/e2e/cypress/e2e/templatesBasic.cy.ts +++ b/apps/e2e/cypress/e2e/templatesBasic.cy.ts @@ -323,6 +323,7 @@ context('Template Basic tests', () => { { url: dynamicMultipleChoiceQuestion.url, isMultipleSelect: true, + useBaseDomain: false, } ); @@ -1547,6 +1548,7 @@ context('Template Basic tests', () => { jsonPath: '$.[*].item', isMultipleSelect: true, firstTopic: true, + useBaseDomain: false, } ); @@ -1567,6 +1569,7 @@ context('Template Basic tests', () => { jsonPath: dynamicMultipleChoiceQuestion.jsonPath, isMultipleSelect: true, firstTopic: true, + useBaseDomain: false, } ); createProposalAndClickDropdownBehavior(); @@ -1589,6 +1592,7 @@ context('Template Basic tests', () => { url: dynamicMultipleChoiceQuestion.url, isMultipleSelect: true, firstTopic: true, + useBaseDomain: false, } ); createProposalAndClickDropdownBehavior(); @@ -1614,6 +1618,7 @@ context('Template Basic tests', () => { isMultipleSelect: true, firstTopic: true, headers: { Authorization: 'Bearer 1234', 'Content-Type': 'text/' }, + useBaseDomain: false, } ); diff --git a/apps/e2e/cypress/fixtures/template_export.json b/apps/e2e/cypress/fixtures/template_export.json index 3d1957f07f..9363048cc5 100644 --- a/apps/e2e/cypress/fixtures/template_export.json +++ b/apps/e2e/cypress/fixtures/template_export.json @@ -458,6 +458,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -473,6 +474,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -785,6 +787,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -1171,6 +1174,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -1186,6 +1190,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -1403,6 +1408,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] diff --git a/apps/e2e/cypress/types/template.d.ts b/apps/e2e/cypress/types/template.d.ts index e1b461ec46..6f4d024054 100644 --- a/apps/e2e/cypress/types/template.d.ts +++ b/apps/e2e/cypress/types/template.d.ts @@ -187,6 +187,7 @@ declare global { options?: { url?: string; jsonPath?: string; + useBaseDomain: boolean; isMultipleSelect?: boolean; type?: 'radio' | 'dropdown'; firstTopic?: boolean; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx index a453e0c2ee..68222bb28b 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx @@ -21,7 +21,7 @@ import InputLabel from '@mui/material/InputLabel'; import { SelectChangeEvent } from '@mui/material/Select'; import { styled } from '@mui/material/styles'; import { Field } from 'formik'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import * as Yup from 'yup'; import CheckboxWithLabel from 'components/common/FormikUICheckboxWithLabel'; @@ -34,6 +34,7 @@ import { ApiCallRequestHeader, DynamicMultipleChoiceConfig, } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; import { urlValidationSchema } from 'utils/helperFunctions'; import { useNaturalKeySchema } from 'utils/userFieldValidationSchema'; @@ -83,6 +84,14 @@ const jsonPathFieldsDocRows = [ }, ]; +const pathNameValidationSchema = Yup.string() + .matches( + /^(?!http|www)/i, + 'Provide a valid pathname, the base domain is already provided' + ) + .matches(/^(?!\/)/, 'Leading slash should not be included') + .required('Pathname is required'); + const CustomizedTableCell = styled(TableCell)(({ theme }) => ({ [`&.${tableCellClasses.head}`]: { background: theme.palette.common.black, @@ -110,6 +119,9 @@ export const QuestionDynamicMultipleChoiceForm = (props: QuestionFormProps) => { const urlValidation = urlValidationSchema(); const [showIsMultipleSelectCheckbox, setShowIsMultipleSelectCheckbox] = useState(config.variant === 'dropdown'); + const [useBaseDomain, setUseBaseDomain] = useState( + config.useBaseDomain ?? false + ); const availableVariantOptions = [ { label: 'Radio', value: 'radio' }, @@ -119,6 +131,25 @@ export const QuestionDynamicMultipleChoiceForm = (props: QuestionFormProps) => { const [isJsonPathFieldDocPopupOpen, setIsJsonPathFieldDocPopupOpen] = useState(false); + const [isBaseURLCheckBoxPopupOpen, setIsBaseURLCheckBoxPopupOpen] = + useState(false); + + const api = useDataApi(); + const [serverConfig, setServerConfig] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const { ServerConfig } = await api().getServerConfig(); + setServerConfig(ServerConfig.baseURL); + } catch (err) { + console.error('Failed to fetch server config', err); + } + }; + + fetchConfig(); + }, [api]); + return ( { config: Yup.object({ required: Yup.bool(), variant: Yup.string().required('Variant is required'), - url: urlValidation, + + url: Yup.string().when('useBaseDomain', { + is: true, + then: (schema) => schema.concat(pathNameValidationSchema), + otherwise: (schema) => schema.concat(urlValidation), + }), + + useBaseDomain: Yup.bool(), jsonPath: Yup.string(), apiRequestHeaders: Yup.array(), }), })} > - {() => ( + {({ setFieldValue }) => ( <> { Link - - - - +
+ {useBaseDomain && ( + + {`${serverConfig}/`} + + )} + + +
+
+ ) => { + const checked = e.target.checked; + setUseBaseDomain(checked); + setFieldValue('config.useBaseDomain', checked); + }} + /> + + setIsBaseURLCheckBoxPopupOpen(true)} + > + + + setIsBaseURLCheckBoxPopupOpen(false)} + aria-labelledby="customized-dialog-title" + > + +
+ Instead of providing a full URL to retrieve a list for + your question, this option allows you to use the current + server's domain and specify only the relative path. +
+
+ This is particularly useful for GraphQL queries that + access the database. Please note that while the domain + displayed in the UI reflects the current deployment, it + is not stored. At runtime, the system will automatically + resolve and apply the current servers domain. +
+
+ + + +
+
+
+
+ + JsonPath { const config = props.questionRel.config as DynamicMultipleChoiceConfig; const [showIsMultipleSelectCheckbox, setShowIsMultipleSelectCheckbox] = useState(config.variant === 'dropdown'); + const [useBaseDomain, setUseBaseDomain] = useState( + config.useBaseDomain ?? false + ); + const [isBaseURLCheckBoxPopupOpen, setIsBaseURLCheckBoxPopupOpen] = + useState(false); const availableVariantOptions = [ { label: 'Radio', value: 'radio' }, @@ -39,6 +62,22 @@ export const QuestionTemplateRelationDynamicMultipleChoiceForm = ( const urlValidation = urlValidationSchema(); + const api = useDataApi(); + const [serverConfig, setServerConfig] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const { ServerConfig } = await api().getServerConfig(); + setServerConfig(ServerConfig.baseURL); + } catch (err) { + console.error('Failed to fetch server config', err); + } + }; + + fetchConfig(); + }, [api]); + return ( schema.concat(pathNameValidationSchema), + otherwise: (schema) => schema.concat(urlValidation), + }), }), })} > - {() => ( + {({ setFieldValue }) => ( <> @@ -103,17 +146,94 @@ export const QuestionTemplateRelationDynamicMultipleChoiceForm = ( Link - + +
+ {useBaseDomain && ( + + {`${serverConfig}/`} + + )} + + +
+ +
+ ) => { + const checked = e.target.checked; + setUseBaseDomain(checked); + setFieldValue('config.useBaseDomain', checked); + }} + /> + + setIsBaseURLCheckBoxPopupOpen(true)} + > + + + setIsBaseURLCheckBoxPopupOpen(false)} + aria-labelledby="customized-dialog-title" + > + +
+ Instead of providing a full URL to retrieve a list for + your question, this option allows you to use the current + server's domain and specify only the relative path. +
+
+ This is particularly useful for GraphQL queries that + access the database. Please note that while the domain + displayed in the UI reflects the current deployment, it + is not stored. At runtime, the system will automatically + resolve and apply the current servers domain. +
+
+ + + +
+
+
- - + + + JsonPath Date: Mon, 6 Jul 2026 16:29:21 +0100 Subject: [PATCH 10/11] feat: diamond production/staging settings no longer gets overwritten on deployments (#1619) --- .../backend/src/config/dependencyConfigDLS.ts | 6 ++--- .../src/config/dls/configureDLSEnvironment.ts | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/config/dependencyConfigDLS.ts b/apps/backend/src/config/dependencyConfigDLS.ts index d942f98755..0e506f2ec2 100644 --- a/apps/backend/src/config/dependencyConfigDLS.ts +++ b/apps/backend/src/config/dependencyConfigDLS.ts @@ -1,15 +1,15 @@ import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; import 'reflect-metadata'; +import { getSecondsFromAllocationTimeUnits } from './base/allocationTimeUnitConverter'; +import { configureDLSEnvironment } from './dls/configureDLSEnvironment'; import { Tokens } from './Tokens'; +import { mapClass, mapValue } from './utils'; import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; import { DLSUserAuthorization } from '../auth/DLSUserAuthorization'; -import { getSecondsFromAllocationTimeUnits } from './base/allocationTimeUnitConverter'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; -import { configureDLSEnvironment } from './dls/configureDLSEnvironment'; -import { mapClass, mapValue } from './utils'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; diff --git a/apps/backend/src/config/dls/configureDLSEnvironment.ts b/apps/backend/src/config/dls/configureDLSEnvironment.ts index cff8fe28e9..640993ac0a 100644 --- a/apps/backend/src/config/dls/configureDLSEnvironment.ts +++ b/apps/backend/src/config/dls/configureDLSEnvironment.ts @@ -3,6 +3,7 @@ import { container } from 'tsyringe'; import { AdminDataSource } from '../../datasources/AdminDataSource'; import { FeatureId } from '../../models/Feature'; import { SettingsId } from '../../models/Settings'; +import { isDevelopment, isStaging } from '../../utils/helperFunctions'; import { setTimezone, setDateTimeFormats } from '../setTimezoneAndFormat'; import { Tokens } from '../Tokens'; import { updateOIDCSettings } from '../updateOIDCSettings'; @@ -16,7 +17,7 @@ function getBaseURL() { return url; } -async function setDLSColourTheme() { +async function setDLSColourTheme(primaryMainColour: string) { const db = container.resolve(Tokens.AdminDataSource); await db.waitForDBUpgrade(); @@ -28,7 +29,7 @@ async function setDLSColourTheme() { }), db.updateSettings({ settingsId: SettingsId.PALETTE_PRIMARY_MAIN, - settingsValue: '#202945', + settingsValue: primaryMainColour, }), db.updateSettings({ settingsId: SettingsId.PALETTE_PRIMARY_LIGHT, @@ -142,13 +143,20 @@ async function enableDefaultDLSFeatures() { } async function configureDLSEnvironment() { - await Promise.all([ - setDLSColourTheme(), - enableDefaultDLSFeatures(), - setTimezone(), - setDateTimeFormats(), - updateOIDCSettings(), - ]); + if (isDevelopment) { + await setDLSColourTheme('#b3022b'); + } + if (isStaging) { + await setDLSColourTheme('#8B008B'); + } + if (isDevelopment) { + await Promise.all([ + enableDefaultDLSFeatures(), + setTimezone(), + setDateTimeFormats(), + updateOIDCSettings(), + ]); + } } export { configureDLSEnvironment, getBaseURL }; From 83cc7f01b5b3ac15402945503895966b729e5459 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum <81559866+TCMeldrum@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:02:37 +0100 Subject: [PATCH 11/11] fix: update proposals and assignment columns (#1526) --- .../FapProposalsAndAssignmentsTable.tsx | 39 +++++++++++++++++++ .../src/graphql/fap/getFapProposals.graphql | 3 ++ 2 files changed, 42 insertions(+) diff --git a/apps/frontend/src/components/fap/Proposals/ProposalsView/FapProposalsAndAssignmentsTable.tsx b/apps/frontend/src/components/fap/Proposals/ProposalsView/FapProposalsAndAssignmentsTable.tsx index 8d9c55caa4..3718bba846 100644 --- a/apps/frontend/src/components/fap/Proposals/ProposalsView/FapProposalsAndAssignmentsTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/ProposalsView/FapProposalsAndAssignmentsTable.tsx @@ -35,6 +35,7 @@ import { standardDeviation, } from 'utils/mathFunctions'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; +import { getFullUserName } from 'utils/user'; import withConfirm, { WithConfirmType } from 'utils/withConfirm'; type FapProposalsAndAssignmentsTableProps = { @@ -82,6 +83,18 @@ const FapProposalColumns: Column[] = [ title: 'Title', field: 'proposal.title', }, + { + title: 'Principal Investigator', + render: (rowData) => { + return getFullUserName(rowData.proposal.proposer); + }, + customSort(a, b) { + const name1 = getFullUserName(a.proposal.proposer); + const name2 = getFullUserName(b.proposal.proposer); + + return name1 < name2 ? -1 : 1; + }, + }, { title: 'Status', field: 'proposal.status.name', @@ -93,6 +106,8 @@ const FapProposalColumns: Column[] = [ { title: 'Reviewers', render: (data) => data.assignments?.length, + customSort: (a, b) => + (a.assignments?.length || 0) - (b.assignments?.length || 0), }, { title: 'Reviews', @@ -106,6 +121,29 @@ const FapProposalColumns: Column[] = [ return totalReviews === 0 ? '-' : `${countReviews} / ${totalReviews}`; }, + customSort: (a, b) => { + const totalReviewsA = a.assignments?.length || 0; + const totalReviewsB = b.assignments?.length || 0; + const gradedProposalsA = + a.assignments?.filter( + (assignment) => + assignment.review !== null && assignment.review.grade !== null + ).length || 0; + const gradedProposalsB = + b.assignments?.filter( + (assignment) => + assignment.review !== null && assignment.review.grade !== null + ).length || 0; + + const incompleteReviewsA = totalReviewsA - gradedProposalsA; + const incompleteReviewsB = totalReviewsB - gradedProposalsB; + + if (incompleteReviewsA === incompleteReviewsB) { + return totalReviewsB - totalReviewsA; + } + + return incompleteReviewsA - incompleteReviewsB; + }, }, { title: 'Average grade', @@ -459,6 +497,7 @@ const FapProposalsAndAssignmentsTable = ({ ]} actions={tableActions} options={{ + columnsButton: true, search: true, selection: true, pageSize: pageSize ? +pageSize : Math.min(10, maxPageLength), diff --git a/apps/frontend/src/graphql/fap/getFapProposals.graphql b/apps/frontend/src/graphql/fap/getFapProposals.graphql index df706d1f6d..5d9754d262 100644 --- a/apps/frontend/src/graphql/fap/getFapProposals.graphql +++ b/apps/frontend/src/graphql/fap/getFapProposals.graphql @@ -11,6 +11,9 @@ query getFapProposals($fapId: Int!, $callId: Int, $instrumentId: Int, $legacy: B proposalId proposer { id + firstname + lastname + preferredname institutionId } status {