diff --git a/containers/ecr-viewer/jest.setup.ts b/containers/ecr-viewer/jest.setup.ts index 640162ba2d..c86f0de467 100644 --- a/containers/ecr-viewer/jest.setup.ts +++ b/containers/ecr-viewer/jest.setup.ts @@ -4,3 +4,5 @@ import * as matchers from "jest-extended"; expect.extend(toHaveNoViolations); expect.extend(matchers); +global.TextEncoder = require("util").TextEncoder; +global.TextDecoder = require("util").TextDecoder; diff --git a/containers/ecr-viewer/package-lock.json b/containers/ecr-viewer/package-lock.json index 5c6e968dbb..9eeb7678db 100644 --- a/containers/ecr-viewer/package-lock.json +++ b/containers/ecr-viewer/package-lock.json @@ -21,10 +21,12 @@ "@vercel/otel": "^1.8.2", "classnames": "^2.5.1", "date-fns": "^3.4.0", + "dompurify": "^3.1.4", "fhirpath": "^3.10.4", "html-react-parser": "^5.1.4", "jose": "5.2.2", "js-yaml": "4.1.0", + "jsdom": "^24.0.0", "next": "14.0.3", "pg-promise": "^11.6.0", "react": "^18", @@ -35,6 +37,7 @@ "@smithy/util-stream": "^2.1.2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^15.0.7", + "@types/dompurify": "^3.0.5", "@types/fhir": "^0.0.40", "@types/jest-axe": "^3.5.9", "@types/js-yaml": "^4.0.9", @@ -6632,6 +6635,15 @@ "@types/ssh2": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", @@ -7016,6 +7028,12 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -7820,8 +7838,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -8693,7 +8710,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -8885,23 +8901,16 @@ "dev": true }, "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "dev": true, + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", "dependencies": { - "cssom": "~0.3.6" + "rrweb-cssom": "^0.6.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true - }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -9024,17 +9033,15 @@ } }, "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/data-view-buffer": { @@ -9122,8 +9129,7 @@ "node_modules/decimal.js": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", - "dev": true + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" }, "node_modules/dedent": { "version": "1.5.3", @@ -9204,7 +9210,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -9391,6 +9396,11 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.4.tgz", + "integrity": "sha512-2gnshi6OshmuKil8rMZuQCGiUF3cUxHY3NGDzUAdUx/NPEe5DVnO8BDoAQouvgwnx0R/+a6jUn36Z0FSdq8vww==" + }, "node_modules/domutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", @@ -10759,7 +10769,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -11242,15 +11251,14 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dependencies": { - "whatwg-encoding": "^2.0.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/html-escaper": { @@ -11298,29 +11306,15 @@ } }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "dependencies": { - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/http-signature": { @@ -11362,7 +11356,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -11830,8 +11823,7 @@ "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, "node_modules/is-regex": { "version": "1.1.4", @@ -12609,6 +12601,201 @@ } } }, + "node_modules/jest-environment-jsdom/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + }, + "node_modules/jest-environment-jsdom/node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jest-environment-jsdom/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -13245,43 +13432,37 @@ } }, "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.0.0.tgz", + "integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==", "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", + "nwsapi": "^2.2.7", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^2.11.2" }, "peerDependenciesMeta": { "canvas": { @@ -13289,31 +13470,6 @@ } } }, - "node_modules/jsdom/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/jsdom/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -13800,7 +13956,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -13809,7 +13964,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -14103,8 +14257,7 @@ "node_modules/nwsapi": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz", - "integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==", - "dev": true + "integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==" }, "node_modules/object-assign": { "version": "4.1.1", @@ -14365,7 +14518,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dev": true, "dependencies": { "entities": "^4.4.0" }, @@ -14916,8 +15068,7 @@ "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" }, "node_modules/pump": { "version": "3.0.0", @@ -14933,7 +15084,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } @@ -14972,8 +15122,7 @@ "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -15192,8 +15341,7 @@ "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/resolve": { "version": "1.22.8", @@ -15359,6 +15507,11 @@ "node": "*" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15449,8 +15602,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { "version": "1.77.2", @@ -15478,7 +15630,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -16111,8 +16262,7 @@ "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, "node_modules/tapable": { "version": "2.2.1", @@ -16318,7 +16468,6 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -16333,21 +16482,19 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, "engines": { "node": ">= 4.0.0" } }, "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/ts-api-utils": { @@ -16618,7 +16765,6 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -16676,15 +16822,14 @@ "dev": true }, "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/walker": { @@ -16712,43 +16857,39 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, "engines": { "node": ">=12" } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dev": true, + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dependencies": { "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.0.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/which": { @@ -16968,7 +17109,6 @@ "version": "8.17.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", - "dev": true, "engines": { "node": ">=10.0.0" }, @@ -16986,19 +17126,17 @@ } }, "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, "node_modules/xmldoc": { "version": "0.4.0", diff --git a/containers/ecr-viewer/package.json b/containers/ecr-viewer/package.json index 0dc9b894a5..669a95927c 100644 --- a/containers/ecr-viewer/package.json +++ b/containers/ecr-viewer/package.json @@ -7,8 +7,8 @@ "dev": "next dev", "local-dev": "npm run setup-local-env && docker compose up db -d && docker-compose logs && export DATABASE_URL=postgres://postgres:pw@localhost:5432/ecr_viewer_db && npm run dev", "setup-local-env": "./setup-env.sh", - "build": "next build", - "start": "next start", + "build": "next build && cp -r .next/static .next/standalone/.next", + "start": "node .next/standalone/server.js", "lint": "next lint", "test": "jest", "test:watch": "jest --watch", @@ -30,10 +30,12 @@ "@vercel/otel": "^1.8.2", "classnames": "^2.5.1", "date-fns": "^3.4.0", + "dompurify": "^3.1.4", "fhirpath": "^3.10.4", "html-react-parser": "^5.1.4", "jose": "5.2.2", "js-yaml": "4.1.0", + "jsdom": "^24.0.0", "next": "14.0.3", "pg-promise": "^11.6.0", "react": "^18", @@ -44,6 +46,7 @@ "@smithy/util-stream": "^2.1.2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^15.0.7", + "@types/dompurify": "^3.0.5", "@types/fhir": "^0.0.40", "@types/jest-axe": "^3.5.9", "@types/js-yaml": "^4.0.9", diff --git a/containers/ecr-viewer/src/app/DataDisplay.tsx b/containers/ecr-viewer/src/app/DataDisplay.tsx index 712a5df2a1..51e772e2f0 100644 --- a/containers/ecr-viewer/src/app/DataDisplay.tsx +++ b/containers/ecr-viewer/src/app/DataDisplay.tsx @@ -1,3 +1,4 @@ +"use client"; import React, { ReactNode, useEffect, useState } from "react"; import { Button } from "@trussworks/react-uswds"; import classNames from "classnames"; diff --git a/containers/ecr-viewer/src/app/ToolTipElement.tsx b/containers/ecr-viewer/src/app/ToolTipElement.tsx index 43c99f36be..c53e55a219 100644 --- a/containers/ecr-viewer/src/app/ToolTipElement.tsx +++ b/containers/ecr-viewer/src/app/ToolTipElement.tsx @@ -1,3 +1,4 @@ +"use client"; import React from "react"; import { Tooltip } from "@trussworks/react-uswds"; diff --git a/containers/ecr-viewer/src/app/api/fhir-data/db.ts b/containers/ecr-viewer/src/app/api/fhir-data/db.ts deleted file mode 100644 index 2243003839..0000000000 --- a/containers/ecr-viewer/src/app/api/fhir-data/db.ts +++ /dev/null @@ -1,4 +0,0 @@ -import pgp from "pg-promise"; - -const db_url = process.env.DATABASE_URL || ""; -export const database = pgp()(db_url); diff --git a/containers/ecr-viewer/src/app/api/fhir-data/fhir-data-service.ts b/containers/ecr-viewer/src/app/api/fhir-data/fhir-data-service.ts deleted file mode 100644 index 049d3d5b7c..0000000000 --- a/containers/ecr-viewer/src/app/api/fhir-data/fhir-data-service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import pgPromise from "pg-promise"; -import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; -import { loadYamlConfig, streamToJson } from "../utils"; -import { database } from "@/app/api/fhir-data/db"; - -const s3Client = new S3Client({ region: process.env.AWS_REGION }); - -/** - * Retrieves FHIR data from PostgreSQL database based on eCR ID. - * @param request - The NextRequest object containing the request information. - * @returns A promise resolving to a NextResponse object. - */ -export const get_postgres = async (request: NextRequest) => { - const params = request.nextUrl.searchParams; - const ecr_id = params.get("id") ? params.get("id") : null; - const mappings = loadYamlConfig(); - - const { ParameterizedQuery: PQ } = pgPromise; - const findFhir = new PQ({ - text: "SELECT * FROM fhir WHERE ecr_id = $1", - values: [ecr_id], - }); - try { - if (!mappings) throw Error; - const entry = await database.one(findFhir); - return NextResponse.json( - { fhirBundle: entry.data, fhirPathMappings: mappings }, - { status: 200 }, - ); - } catch (error: any) { - console.error("Error fetching data:", error); - if (error.message == "No data returned from the query.") { - return NextResponse.json( - { message: "eCR ID not found" }, - { status: 404 }, - ); - } else { - return NextResponse.json({ message: error.message }, { status: 500 }); - } - } -}; - -/** - * Retrieves FHIR data from S3 based on eCR ID. - * @param request - The NextRequest object containing the request information. - * @returns A promise resolving to a NextResponse object. - */ -export const get_s3 = async (request: NextRequest) => { - const params = request.nextUrl.searchParams; - const ecr_id = params.get("id"); - const bucketName = process.env.ECR_BUCKET_NAME; - const objectKey = `${ecr_id}.json`; // This could also come from the request, e.g., req.query.key - - try { - const command = new GetObjectCommand({ - Bucket: bucketName, - Key: objectKey, - }); - - const { Body } = await s3Client.send(command); - const content = await streamToJson(Body); - - return NextResponse.json( - { fhirBundle: content, fhirPathMappings: loadYamlConfig() }, - { status: 200 }, - ); - } catch (error: any) { - console.error("S3 GetObject error:", error); - return NextResponse.json({ message: error.message }, { status: 500 }); - } -}; diff --git a/containers/ecr-viewer/src/app/api/fhir-data/route.ts b/containers/ecr-viewer/src/app/api/fhir-data/route.ts deleted file mode 100644 index 584e17c529..0000000000 --- a/containers/ecr-viewer/src/app/api/fhir-data/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { get_s3, get_postgres } from "./fhir-data-service"; - -const S3_SOURCE = "s3"; -const POSTGRES_SOURCE = "postgres"; - -/** - * Handles GET requests by fetching data from different sources based on the environment configuration. - * It supports fetching from S3 and Postgres. If the `SOURCE` environment variable is not set to - * a supported source, it returns a JSON response indicating an invalid source. - * @param request - The incoming request object provided by Next.js. - * @returns A promise that resolves to a `NextResponse` object - * if the source is invalid, or the result of fetching from the specified source. - * The specific return type (e.g., the type returned by `get_s3` or `get_postgres`) - * may vary based on the source and is thus marked as `unknown`. - */ -export async function GET(request: NextRequest) { - if (process.env.SOURCE === S3_SOURCE) { - return get_s3(request); - } else if (process.env.SOURCE === POSTGRES_SOURCE) { - return await get_postgres(request); - } else { - return NextResponse.json({ message: "Invalid source" }, { status: 500 }); - } -} diff --git a/containers/ecr-viewer/src/app/api/services/ecrDataService.ts b/containers/ecr-viewer/src/app/api/services/ecrDataService.ts new file mode 100644 index 0000000000..66be622ccb --- /dev/null +++ b/containers/ecr-viewer/src/app/api/services/ecrDataService.ts @@ -0,0 +1,52 @@ +import pgPromise from "pg-promise"; +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { streamToJson } from "@/app/api/services/utils"; +import { database } from "@/app/api/services/db"; + +const S3_SOURCE = "s3"; +const POSTGRES_SOURCE = "postgres"; +const s3Client = new S3Client({ region: process.env.AWS_REGION }); + +/** + * Get eCR data based on the ecrID. + * @param ecrId - The id of the eCR. + * @returns A promise resolving to eCR data. + */ +export const getEcrData = async (ecrId: string) => { + if (process.env.SOURCE === S3_SOURCE) { + return await getS3Data(ecrId); + } else if (process.env.SOURCE === POSTGRES_SOURCE) { + return await getPostgresData(ecrId); + } +}; + +/** + * Retrieves FHIR data from Postgres based on eCR ID. + * @param ecrId - The id of the ecr. + * @returns A promise resolving to eCR data. + */ +const getPostgresData = async (ecrId: string) => { + const { ParameterizedQuery: PQ } = pgPromise; + const findFhir = new PQ({ + text: "SELECT * FROM fhir WHERE ecr_id = $1", + values: [ecrId], + }); + return (await database.one(findFhir)).data; +}; + +/** + * Retrieves FHIR data from S3 based on eCR ID. + * @param ecrId - The id of the ecr. + * @returns A promise resolving to eCR data. + */ +const getS3Data = async (ecrId: string) => { + const bucketName = process.env.ECR_BUCKET_NAME; + const objectKey = `${ecrId}.json`; // This could also come from the request, e.g., req.query.key + const command = new GetObjectCommand({ + Bucket: bucketName, + Key: objectKey, + }); + + const { Body } = await s3Client.send(command); + return await streamToJson(Body); +}; diff --git a/containers/ecr-viewer/src/app/api/fhirPath.yml b/containers/ecr-viewer/src/app/api/services/fhirPath.yml similarity index 100% rename from containers/ecr-viewer/src/app/api/fhirPath.yml rename to containers/ecr-viewer/src/app/api/services/fhirPath.yml diff --git a/containers/ecr-viewer/src/app/api/utils.ts b/containers/ecr-viewer/src/app/api/services/utils.ts similarity index 91% rename from containers/ecr-viewer/src/app/api/utils.ts rename to containers/ecr-viewer/src/app/api/services/utils.ts index cf135fcc54..815dde10f7 100644 --- a/containers/ecr-viewer/src/app/api/utils.ts +++ b/containers/ecr-viewer/src/app/api/services/utils.ts @@ -8,7 +8,10 @@ import { PathMappings } from "@/app/utils"; * @returns An object representing the path mappings defined in the YAML configuration file. */ export function loadYamlConfig(): PathMappings { - const filePath = path.join(process.cwd(), "src/app/api/fhirPath.yml"); + const filePath = path.join( + process.cwd(), + "src/app/api/services/fhirPath.yml", + ); const fileContents = fs.readFileSync(filePath, "utf8"); return yaml.load(fileContents); } diff --git a/containers/ecr-viewer/src/app/services/formatService.tsx b/containers/ecr-viewer/src/app/services/formatService.tsx index cf386b75b1..0b218f20bc 100644 --- a/containers/ecr-viewer/src/app/services/formatService.tsx +++ b/containers/ecr-viewer/src/app/services/formatService.tsx @@ -1,29 +1,6 @@ import React from "react"; import { ToolTipElement } from "@/app/ToolTipElement"; -interface Metadata { - [key: string]: string; -} - -export interface TableRow { - [key: string]: { - value: any; - metadata: Metadata; - }; -} - -export interface TableJson { - resultId?: string; - resultName?: string; - tables?: TableRow[][]; -} - -export interface TableJson { - resultId?: string; - resultName?: string; - tables?: TableRow[][]; -} - /** * Formats a person's name using given name(s), family name, optional prefix(es), and optional suffix(es). * @param given - Optional array of given name(s). @@ -279,84 +256,6 @@ export const formatString = (input: string): string => { return result; }; -/** - * Parses an HTML string containing tables or a list of tables and converts each table into a JSON array of objects. - * Each
  • item represents a different lab result. The resulting JSON objects contain the data-id (Result ID) - * and text content of the
  • items, along with an array of JSON representations of the tables contained within each
  • item. - * @param htmlString - The HTML string containing tables to be parsed. - * @returns - An array of JSON objects representing the list items and their tables from the HTML string. - * @example @returns [{resultId: 'Result.123', resultName: 'foo', tables: [{}, {},...]}, ...] - */ -export function formatTablesToJSON(htmlString: string): TableJson[] { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlString, "text/html"); - const jsonArray: any[] = []; - const liArray = doc.querySelectorAll("li"); - if (liArray.length > 0) { - liArray.forEach((li) => { - const tables: any[] = []; - const resultId = li.getAttribute("data-id"); - const resultName = li.childNodes[0].textContent?.trim() ?? ""; - li.querySelectorAll("table").forEach((table) => { - tables.push(processTable(table)); - }); - jsonArray.push({ resultId, resultName, tables }); - }); - } else { - doc.querySelectorAll("table").forEach((table) => { - const resultName = table.caption?.textContent; - const resultId = table.getAttribute("data-id") ?? undefined; - jsonArray.push({ resultId, resultName, tables: [processTable(table)] }); - }); - } - - return jsonArray; -} - -/** - * Processes a single HTML table element, extracting data from rows and cells, and converts it into a JSON array of objects. - * This function extracts data from and elements within the provided table element. - * The content of elements is used as keys in the generated JSON objects. - * @param table - The HTML table element to be processed. - * @returns - An array of JSON objects representing the rows and cells of the table. - */ -function processTable(table: Element): TableRow[] { - const jsonArray: any[] = []; - const rows = table.querySelectorAll("tr"); - const keys: string[] = []; - - rows[0].querySelectorAll("th").forEach((header) => { - keys.push(header.textContent?.trim() ?? ""); - }); - - rows.forEach((row, rowIndex) => { - // Skip the first row as it contains headers - if (rowIndex === 0) return; - - const obj: TableRow = {}; - row.querySelectorAll("td").forEach((cell, cellIndex) => { - const key = keys[cellIndex]; - - const metaData: Metadata = {}; - const attributes = cell.attributes || []; - for (const element of attributes) { - const attrName = element.nodeName; - const attrValue = element.nodeValue; - if (attrName && attrValue) { - metaData[attrName] = attrValue; - } - } - obj[key] = { - value: cell.textContent?.trim() ?? "", - metadata: metaData, - }; - }); - jsonArray.push(obj); - }); - - return jsonArray; -} - /** * Extracts and concatenates all sequences of numbers and periods from each string in the input array, * excluding any leading and trailing periods in the first matched sequence of each string. diff --git a/containers/ecr-viewer/src/app/services/formatTablesToJSON.ts b/containers/ecr-viewer/src/app/services/formatTablesToJSON.ts new file mode 100644 index 0000000000..11128ee545 --- /dev/null +++ b/containers/ecr-viewer/src/app/services/formatTablesToJSON.ts @@ -0,0 +1,97 @@ +import { JSDOM } from "jsdom"; + +interface Metadata { + [key: string]: string; +} + +export interface TableRow { + [key: string]: { + value: any; + metadata: Metadata; + }; +} + +export interface TableJson { + resultId?: string; + resultName?: string; + tables?: TableRow[][]; +} + +/** + * Parses an HTML string containing tables or a list of tables and converts each table into a JSON array of objects. + * Each
  • item represents a different lab result. The resulting JSON objects contain the data-id (Result ID) + * and text content of the
  • items, along with an array of JSON representations of the tables contained within each
  • item. + * @param htmlString - The HTML string containing tables to be parsed. + * @returns - An array of JSON objects representing the list items and their tables from the HTML string. + * @example @returns [{resultId: 'Result.123', resultName: 'foo', tables: [{}, {},...]}, ...] + */ +export const formatTablesToJSON = (htmlString: string): TableJson[] => { + // const parser = new DOMParser(); + // const doc = parser.parseFromString(htmlString, "text/html"); + const doc = new JSDOM(htmlString).window.document; + const jsonArray: any[] = []; + const liArray = doc.querySelectorAll("li"); + if (liArray.length > 0) { + liArray.forEach((li) => { + const tables: any[] = []; + const resultId = li.getAttribute("data-id"); + const resultName = li.childNodes[0].textContent?.trim() ?? ""; + li.querySelectorAll("table").forEach((table) => { + tables.push(processTable(table)); + }); + jsonArray.push({ resultId, resultName, tables }); + }); + } else { + doc.querySelectorAll("table").forEach((table) => { + const resultName = table.caption?.textContent; + const resultId = table.getAttribute("data-id") ?? undefined; + jsonArray.push({ resultId, resultName, tables: [processTable(table)] }); + }); + } + + return jsonArray; +}; + +/** + * Processes a single HTML table element, extracting data from rows and cells, and converts it into a JSON array of objects. + * This function extracts data from and elements within the provided table element. + * The content of elements is used as keys in the generated JSON objects. + * @param table - The HTML table element to be processed. + * @returns - An array of JSON objects representing the rows and cells of the table. + */ +function processTable(table: Element): TableRow[] { + const jsonArray: any[] = []; + const rows = table.querySelectorAll("tr"); + const keys: string[] = []; + + rows[0].querySelectorAll("th").forEach((header) => { + keys.push(header.textContent?.trim() ?? ""); + }); + + rows.forEach((row, rowIndex) => { + // Skip the first row as it contains headers + if (rowIndex === 0) return; + + const obj: TableRow = {}; + row.querySelectorAll("td").forEach((cell, cellIndex) => { + const key = keys[cellIndex]; + + const metaData: Metadata = {}; + const attributes = cell.attributes || []; + for (const element of attributes) { + const attrName = element.nodeName; + const attrValue = element.nodeValue; + if (attrName && attrValue) { + metaData[attrName] = attrValue; + } + } + obj[key] = { + value: cell.textContent?.trim() ?? "", + metadata: metaData, + }; + }); + jsonArray.push(obj); + }); + + return jsonArray; +} diff --git a/containers/ecr-viewer/src/app/services/labsService.tsx b/containers/ecr-viewer/src/app/services/labsService.tsx index affca94324..ecc16fd1da 100644 --- a/containers/ecr-viewer/src/app/services/labsService.tsx +++ b/containers/ecr-viewer/src/app/services/labsService.tsx @@ -5,16 +5,18 @@ import { evaluate } from "@/app/view-data/utils/evaluate"; import { AccordionLabResults } from "@/app/view-data/components/AccordionLabResults"; import { formatDateTime, - formatTablesToJSON, extractNumbersAndPeriods, formatAddress, formatPhoneNumber, - TableJson, } from "@/app/services/formatService"; import { ObservationComponent } from "fhir/r4b"; import EvaluateTable from "@/app/view-data/components/EvaluateTable"; import { evaluateReference, evaluateValue } from "./evaluateFhirDataService"; import { DataDisplay, DisplayDataProps } from "@/app/DataDisplay"; +import { + formatTablesToJSON, + TableJson, +} from "@/app/services/formatTablesToJSON"; export interface LabReport { result: Array; diff --git a/containers/ecr-viewer/src/app/tests/api/loadYamlConfig.test.ts b/containers/ecr-viewer/src/app/tests/api/loadYamlConfig.test.ts index a877547e73..b198a2c8c5 100644 --- a/containers/ecr-viewer/src/app/tests/api/loadYamlConfig.test.ts +++ b/containers/ecr-viewer/src/app/tests/api/loadYamlConfig.test.ts @@ -1,5 +1,5 @@ import "@testing-library/jest-dom"; -import { loadYamlConfig } from "@/app/api/utils"; +import { loadYamlConfig } from "@/app/api/services/utils"; describe("loadYamlConfig", () => { it("returns the yaml config", () => { diff --git a/containers/ecr-viewer/src/app/tests/components/ActiveProblems.test.tsx b/containers/ecr-viewer/src/app/tests/components/ActiveProblems.test.tsx index e06f3ddf61..a32fa5c3c3 100644 --- a/containers/ecr-viewer/src/app/tests/components/ActiveProblems.test.tsx +++ b/containers/ecr-viewer/src/app/tests/components/ActiveProblems.test.tsx @@ -1,18 +1,14 @@ import { render, screen } from "@testing-library/react"; import { axe } from "jest-axe"; -import fs from "fs"; -import YAML from "yaml"; import { Bundle, Condition } from "fhir/r4"; import BundleWithPatient from "@/app/tests/assets/BundlePatient.json"; import { returnProblemsTable } from "@/app/view-data/components/common"; +import { loadYamlConfig } from "@/app/api/services/utils"; describe("Active Problems Table", () => { let container: HTMLElement; beforeEach(() => { - const fhirPathFile = fs - .readFileSync("./src/app/api/fhirPath.yml", "utf8") - .toString(); - const fhirPathMappings = YAML.parse(fhirPathFile); + const fhirPathMappings = loadYamlConfig(); const activeProblemsData: Condition[] = [ { diff --git a/containers/ecr-viewer/src/app/tests/components/Clinical.test.tsx b/containers/ecr-viewer/src/app/tests/components/Clinical.test.tsx index fa68300894..c1dd35766f 100644 --- a/containers/ecr-viewer/src/app/tests/components/Clinical.test.tsx +++ b/containers/ecr-viewer/src/app/tests/components/Clinical.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { render } from "@testing-library/react"; import { axe } from "jest-axe"; import ClinicalInfo from "../../view-data/components/ClinicalInfo"; -import { loadYamlConfig } from "@/app/api/utils"; +import { loadYamlConfig } from "@/app/api/services/utils"; import { Procedure } from "fhir/r4"; import { evaluateClinicalData, diff --git a/containers/ecr-viewer/src/app/tests/components/EvaluateTable.test.tsx b/containers/ecr-viewer/src/app/tests/components/EvaluateTable.test.tsx index bc603506ea..4b4570426c 100644 --- a/containers/ecr-viewer/src/app/tests/components/EvaluateTable.test.tsx +++ b/containers/ecr-viewer/src/app/tests/components/EvaluateTable.test.tsx @@ -1,4 +1,4 @@ -import { loadYamlConfig } from "@/app/api/utils"; +import { loadYamlConfig } from "@/app/api/services/utils"; import { render, screen } from "@testing-library/react"; import { ColumnInfoInput, PathMappings } from "@/app/utils"; import userEvent from "@testing-library/user-event"; diff --git a/containers/ecr-viewer/src/app/tests/components/Immunizations.test.tsx b/containers/ecr-viewer/src/app/tests/components/Immunizations.test.tsx index b9cca42706..dcf08028ed 100644 --- a/containers/ecr-viewer/src/app/tests/components/Immunizations.test.tsx +++ b/containers/ecr-viewer/src/app/tests/components/Immunizations.test.tsx @@ -1,18 +1,14 @@ import { render } from "@testing-library/react"; import { axe } from "jest-axe"; -import fs from "fs"; -import YAML from "yaml"; import { Bundle, Immunization } from "fhir/r4"; import BundleClinicalInfo from "@/app/tests/assets/BundleClinicalInfo.json"; import { returnImmunizations } from "@/app/view-data/components/common"; +import { loadYamlConfig } from "@/app/api/services/utils"; describe("Immunizations Table", () => { let container: HTMLElement; beforeAll(() => { - const fhirPathFile = fs - .readFileSync("./src/app/api/fhirPath.yml", "utf8") - .toString(); - const fhirPathMappings = YAML.parse(fhirPathFile); + const fhirPathMappings = loadYamlConfig(); const immunizationsData = [ { diff --git a/containers/ecr-viewer/src/app/tests/components/LabInfo.test.tsx b/containers/ecr-viewer/src/app/tests/components/LabInfo.test.tsx index 12e42ca41b..b8c0306795 100644 --- a/containers/ecr-viewer/src/app/tests/components/LabInfo.test.tsx +++ b/containers/ecr-viewer/src/app/tests/components/LabInfo.test.tsx @@ -3,7 +3,7 @@ import LabInfo from "@/app/view-data/components/LabInfo"; import userEvent from "@testing-library/user-event"; import React from "react"; import BundleLab from "@/app/tests/assets/BundleLab.json"; -import { loadYamlConfig } from "@/app/api/utils"; +import { loadYamlConfig } from "@/app/api/services/utils"; import { Bundle } from "fhir/r4"; import { evaluateLabInfoData } from "@/app/services/labsService"; diff --git a/containers/ecr-viewer/src/app/tests/view-data.test.tsx b/containers/ecr-viewer/src/app/tests/components/Metric.test.tsx similarity index 73% rename from containers/ecr-viewer/src/app/tests/view-data.test.tsx rename to containers/ecr-viewer/src/app/tests/components/Metric.test.tsx index a2804a5b5d..38b975e271 100644 --- a/containers/ecr-viewer/src/app/tests/view-data.test.tsx +++ b/containers/ecr-viewer/src/app/tests/components/Metric.test.tsx @@ -1,18 +1,18 @@ import React from "react"; import { render } from "@testing-library/react"; -import ECRViewerPage from "../view-data/page"; -jest.mock("../view-data/component-utils", () => ({ +jest.mock("../../view-data/component-utils", () => ({ metrics: jest.fn(), })); -import { metrics } from "../view-data/component-utils"; +import { metrics } from "../../view-data/component-utils"; +import Metric from "@/app/view-data/components/Metric"; describe("ECRViewerPage", () => { it("calls metrics on beforeunload", () => { const metricsMock = metrics as jest.Mock; - const { unmount } = render(); + const { unmount } = render(); // Create a fake event const event = new Event("beforeunload"); diff --git a/containers/ecr-viewer/src/app/tests/fhir-data.test.tsx b/containers/ecr-viewer/src/app/tests/fhir-data.test.tsx index 1f5a7722a0..774ae46286 100644 --- a/containers/ecr-viewer/src/app/tests/fhir-data.test.tsx +++ b/containers/ecr-viewer/src/app/tests/fhir-data.test.tsx @@ -4,9 +4,8 @@ import fs from "fs"; import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { mockClient } from "aws-sdk-client-mock"; -import { GET } from "../api/fhir-data/route"; // Adjust the import path to your actual file path import { sdkStreamMixin } from "@smithy/util-stream"; -import { NextRequest } from "next/server"; +import { getEcrData } from "@/app/api/services/ecrDataService"; const s3Mock = mockClient(S3Client); const stream = sdkStreamMixin( @@ -39,7 +38,6 @@ describe("GET API Route", () => { it("fetches data from S3 and returns a JSON response", async () => { const fakeId = "test-id"; process.env.SOURCE = "s3"; - const request = new NextRequest(`http://localhost?id=${fakeId}`); s3Mock .on(GetObjectCommand, { @@ -50,9 +48,7 @@ describe("GET API Route", () => { Body: stream, }); - const response = await GET(request); - expect(response.status).toBe(200); - const jsonResponse = await response.json(); - expect(jsonResponse.fhirBundle).toBeDefined(); + const response = await getEcrData(fakeId); + expect(response).toBeDefined(); }); }); diff --git a/containers/ecr-viewer/src/app/tests/services/ecrMetadataService.test.ts b/containers/ecr-viewer/src/app/tests/services/ecrMetadataService.test.ts index 8eb75089b3..b52cc45d9a 100644 --- a/containers/ecr-viewer/src/app/tests/services/ecrMetadataService.test.ts +++ b/containers/ecr-viewer/src/app/tests/services/ecrMetadataService.test.ts @@ -1,7 +1,7 @@ import { evaluateEcrMetadata } from "@/app/services/ecrMetadataService"; import { Bundle } from "fhir/r4"; import BundleWithEcrMetadata from "../assets/BundleEcrMetadata.json"; -import { loadYamlConfig } from "@/app/api/utils"; +import { loadYamlConfig } from "@/app/api/services/utils"; describe("Evaluate Ecr Metadata", () => { const mappings = loadYamlConfig(); diff --git a/containers/ecr-viewer/src/app/tests/services/evaluateFhirDataServices.test.ts b/containers/ecr-viewer/src/app/tests/services/evaluateFhirDataServices.test.ts index 4d9e8d4a4e..0c3a748eb5 100644 --- a/containers/ecr-viewer/src/app/tests/services/evaluateFhirDataServices.test.ts +++ b/containers/ecr-viewer/src/app/tests/services/evaluateFhirDataServices.test.ts @@ -1,4 +1,4 @@ -import { loadYamlConfig } from "@/app/api/utils"; +import { loadYamlConfig } from "@/app/api/services/utils"; import { evaluateReference, evaluateValue, diff --git a/containers/ecr-viewer/src/app/tests/services/evaluateFhirDataServices.ts b/containers/ecr-viewer/src/app/tests/services/evaluateFhirDataServices.ts new file mode 100644 index 0000000000..0c3a748eb5 --- /dev/null +++ b/containers/ecr-viewer/src/app/tests/services/evaluateFhirDataServices.ts @@ -0,0 +1,84 @@ +import { loadYamlConfig } from "@/app/api/services/utils"; +import { + evaluateReference, + evaluateValue, +} from "@/app/services/evaluateFhirDataService"; +import BundleWithMiscNotes from "@/app/tests/assets/BundleMiscNotes.json"; +import { Bundle } from "fhir/r4"; +import BundleWithPatient from "@/app/tests/assets/BundlePatient.json"; + +const mappings = loadYamlConfig(); + +describe("Evaluate Reference", () => { + it("should return undefined if resource not found", () => { + const actual = evaluateReference( + BundleWithMiscNotes as unknown as Bundle, + mappings, + "Observation/1234", + ); + + expect(actual).toBeUndefined(); + }); + it("should return the resource if the resource is available", () => { + const actual = evaluateReference( + BundleWithPatient as unknown as Bundle, + mappings, + "Patient/6b6b3c4c-4884-4a96-b6ab-c46406839cea", + ); + + expect(actual.id).toEqual("6b6b3c4c-4884-4a96-b6ab-c46406839cea"); + expect(actual.resourceType).toEqual("Patient"); + }); +}); + +describe("evaluate value", () => { + it("should provide the string in the case of valueString", () => { + const actual = evaluateValue( + { resourceType: "Observation", valueString: "abc" } as any, + "value", + ); + + expect(actual).toEqual("abc"); + }); + it("should provide the string in the case of valueCodeableConcept", () => { + const actual = evaluateValue( + { + resourceType: "Observation", + valueCodeableConcept: { + coding: [ + { + display: "Negative", + }, + ], + }, + } as any, + "value", + ); + + expect(actual).toEqual("Negative"); + }); + describe("Quantity", () => { + it("should provide the value and string unit with a space inbetween", () => { + const actual = evaluateValue( + { + resourceType: "Observation", + valueQuantity: { value: 1, unit: "ft" }, + } as any, + "value", + ); + + expect(actual).toEqual("1 ft"); + }); + it("should provide the value and symbol unit", () => { + const actual = evaluateValue( + { + resourceType: "Observation", + valueQuantity: { value: 1, unit: "%" }, + } as any, + "value", + ); + + expect(actual).toEqual("1%"); + }); + }); +}); diff --git a/containers/ecr-viewer/src/app/tests/services/formatService.test.tsx b/containers/ecr-viewer/src/app/tests/services/formatService.test.tsx index d5badef58b..956a1c0b4d 100644 --- a/containers/ecr-viewer/src/app/tests/services/formatService.test.tsx +++ b/containers/ecr-viewer/src/app/tests/services/formatService.test.tsx @@ -2,7 +2,6 @@ import { formatName, formatDate, extractNumbersAndPeriods, - formatTablesToJSON, truncateLabNameWholeWord, toSentenceCase, removeHtmlElements, @@ -116,97 +115,6 @@ describe("Format Date", () => { }); }); -describe("formatTablesToJSON", () => { - it("should return the JSON object given an HTML string", () => { - const htmlString = - "
  • Lab Test
    ComponentAnalysis Time
    Campylobacter, NAAT01/01/2024 1:00 PM PDT
    Salmonella, NAAT01/01/2024 1:00 PM PDT
    Specimen (Source)Collection TimeReceived Time
    Stool01/01/2024 12:00 PM PDT01/01/2024 12:00 PM PDT
  • "; - const expectedResult = [ - { - resultId: "Result.12345", - resultName: "Lab Test", - tables: [ - [ - { - Component: { - value: "Campylobacter, NAAT", - metadata: { - "data-id": "Result.12345.Comp1Name", - }, - }, - "Analysis Time": { - value: "01/01/2024 1:00 PM PDT", - metadata: {}, - }, - }, - { - Component: { - value: "Salmonella, NAAT", - metadata: { - "data-id": "Result.12345.Comp2Name", - }, - }, - "Analysis Time": { - value: "01/01/2024 1:00 PM PDT", - metadata: {}, - }, - }, - ], - [ - { - "Specimen (Source)": { - value: "Stool", - metadata: { - "data-id": "Result.12345.Specimen", - }, - }, - "Collection Time": { - value: "01/01/2024 12:00 PM PDT", - metadata: {}, - }, - "Received Time": { - value: "01/01/2024 12:00 PM PDT", - metadata: {}, - }, - }, - ], - ], - }, - ]; - - const result = formatTablesToJSON(htmlString); - - expect(result).toEqual(expectedResult); - }); - - it("should return an empty array when HTML string input has no tables", () => { - const htmlString = - "

    Hello, World!

    This HTML string has no tables.

    "; - const expectedResult: [] = []; - - const result = formatTablesToJSON(htmlString); - - expect(result).toEqual(expectedResult); - }); - - it("should return the JSON object given a table html string", () => { - const tableString = - "
    Pending Results
    Name
    test1
    Scheduled Orders
    Name
    test2
    documented as of this encounter\n"; - const expectedResult = [ - { - resultName: "Pending Results", - tables: [[{ Name: { metadata: {}, value: "test1" } }]], - }, - { - resultName: "Scheduled Orders", - tables: [[{ Name: { metadata: {}, value: "test2" } }]], - }, - ]; - const result = formatTablesToJSON(tableString); - - expect(result).toEqual(expectedResult); - }); -}); - describe("extractNumbersAndPeriods", () => { it("should return the correctly formatted sequence of numbers and periods", () => { const inputArray = [ diff --git a/containers/ecr-viewer/src/app/tests/services/formatTablesToJSON.test.ts b/containers/ecr-viewer/src/app/tests/services/formatTablesToJSON.test.ts new file mode 100644 index 0000000000..e245896ff1 --- /dev/null +++ b/containers/ecr-viewer/src/app/tests/services/formatTablesToJSON.test.ts @@ -0,0 +1,92 @@ +import { formatTablesToJSON } from "@/app/services/formatTablesToJSON"; + +describe("formatTablesToJSON", () => { + it("should return the JSON object given an HTML string", () => { + const htmlString = + "
  • Lab Test
    ComponentAnalysis Time
    Campylobacter, NAAT01/01/2024 1:00 PM PDT
    Salmonella, NAAT01/01/2024 1:00 PM PDT
    Specimen (Source)Collection TimeReceived Time
    Stool01/01/2024 12:00 PM PDT01/01/2024 12:00 PM PDT
  • "; + const expectedResult = [ + { + resultId: "Result.12345", + resultName: "Lab Test", + tables: [ + [ + { + Component: { + value: "Campylobacter, NAAT", + metadata: { + "data-id": "Result.12345.Comp1Name", + }, + }, + "Analysis Time": { + value: "01/01/2024 1:00 PM PDT", + metadata: {}, + }, + }, + { + Component: { + value: "Salmonella, NAAT", + metadata: { + "data-id": "Result.12345.Comp2Name", + }, + }, + "Analysis Time": { + value: "01/01/2024 1:00 PM PDT", + metadata: {}, + }, + }, + ], + [ + { + "Specimen (Source)": { + value: "Stool", + metadata: { + "data-id": "Result.12345.Specimen", + }, + }, + "Collection Time": { + value: "01/01/2024 12:00 PM PDT", + metadata: {}, + }, + "Received Time": { + value: "01/01/2024 12:00 PM PDT", + metadata: {}, + }, + }, + ], + ], + }, + ]; + + const result = formatTablesToJSON(htmlString); + + expect(result).toEqual(expectedResult); + }); + + it("should return an empty array when HTML string input has no tables", () => { + const htmlString = + "

    Hello, World!

    This HTML string has no tables.

    "; + const expectedResult: [] = []; + + const result = formatTablesToJSON(htmlString); + + expect(result).toEqual(expectedResult); + }); + + it("should return the JSON object given a table html string", () => { + const tableString = + "
    Pending Results
    Name
    test1
    Scheduled Orders
    Name
    test2
    documented as of this encounter\n"; + const expectedResult = [ + { + resultName: "Pending Results", + tables: [[{ Name: { metadata: {}, value: "test1" } }]], + }, + { + resultName: "Scheduled Orders", + tables: [[{ Name: { metadata: {}, value: "test2" } }]], + }, + ]; + const result = formatTablesToJSON(tableString); + + expect(result).toEqual(expectedResult); + }); +}); diff --git a/containers/ecr-viewer/src/app/tests/services/labsService.test.tsx b/containers/ecr-viewer/src/app/tests/services/labsService.test.tsx index 40c9366446..fd15b83fc8 100644 --- a/containers/ecr-viewer/src/app/tests/services/labsService.test.tsx +++ b/containers/ecr-viewer/src/app/tests/services/labsService.test.tsx @@ -1,4 +1,4 @@ -import { loadYamlConfig } from "@/app/api/utils"; +import { loadYamlConfig } from "@/app/api/services/utils"; import BundleLab from "../assets/BundleLab.json"; import { Bundle, Observation } from "fhir/r4"; import { evaluate } from "fhirpath"; diff --git a/containers/ecr-viewer/src/app/tests/utils.test.tsx b/containers/ecr-viewer/src/app/tests/utils.test.tsx index 5e7a0f4ee0..174fdd065a 100644 --- a/containers/ecr-viewer/src/app/tests/utils.test.tsx +++ b/containers/ecr-viewer/src/app/tests/utils.test.tsx @@ -1,5 +1,5 @@ import { isDataAvailable } from "@/app/utils"; -import { loadYamlConfig } from "@/app/api/utils"; +import { loadYamlConfig } from "@/app/api/services/utils"; import { Bundle } from "fhir/r4"; import BundleWithTravelHistory from "./assets/BundleTravelHistory.json"; import BundleWithPatient from "./assets/BundlePatient.json"; diff --git a/containers/ecr-viewer/src/app/view-data/components/BuildRow.tsx b/containers/ecr-viewer/src/app/view-data/components/BuildRow.tsx new file mode 100644 index 0000000000..de66cd2b6b --- /dev/null +++ b/containers/ecr-viewer/src/app/view-data/components/BuildRow.tsx @@ -0,0 +1,70 @@ +"use client"; +import React, { ReactNode, useState } from "react"; +import { Button } from "@trussworks/react-uswds"; +import { ColumnInfoInput } from "@/app/utils"; + +type ClientColumnInfoInput = Omit; + +interface BuildRowProps { + columns: ClientColumnInfoInput[]; + rowValues: ReactNode[]; +} + +/** + * Builds a row for a table based on provided columns, mappings, and entry data. + * @param props - The properties object containing columns, mappings, and entry data. + * @param props.columns - An array of column objects defining the structure of the row. + * @param props.rowValues - Values to be inserted into the row + * @returns - The JSX element representing the constructed row. + */ +const BuildRow: React.FC = ({ + columns, + rowValues, +}: BuildRowProps) => { + const [hiddenComment, setHiddenComment] = useState(true); + + let hiddenRows: React.JSX.Element[] = []; + let rowCells = columns.map((column, index) => { + let rowCellData = rowValues[index]; + if (!rowCellData) { + rowCellData = No data; + } else if (column.hiddenBaseText) { + hiddenRows.push( + + + {rowCellData} + + , + ); + rowCellData = ( + + ); + } + return ( + + {rowCellData} + + ); + }); + + if (hiddenRows) { + return ( + + {rowCells} + {...hiddenRows} + + ); + } else { + return {rowCells}; + } +}; + +export default BuildRow; diff --git a/containers/ecr-viewer/src/app/view-data/components/EcrSummary.tsx b/containers/ecr-viewer/src/app/view-data/components/EcrSummary.tsx index 81c3100aaa..9a4dec9c3f 100644 --- a/containers/ecr-viewer/src/app/view-data/components/EcrSummary.tsx +++ b/containers/ecr-viewer/src/app/view-data/components/EcrSummary.tsx @@ -34,9 +34,9 @@ const EcrSummary: React.FC = ({ About the Patient
    - {patientDetails.map((item) => ( - - ))} + {patientDetails.map((item) => { + return ; + })}
    diff --git a/containers/ecr-viewer/src/app/view-data/components/EvaluateTable.tsx b/containers/ecr-viewer/src/app/view-data/components/EvaluateTable.tsx index ca3b558efe..d755e79722 100644 --- a/containers/ecr-viewer/src/app/view-data/components/EvaluateTable.tsx +++ b/containers/ecr-viewer/src/app/view-data/components/EvaluateTable.tsx @@ -1,15 +1,11 @@ import { Element } from "fhir/r4"; import { ColumnInfoInput, PathMappings } from "@/app/utils"; -import { Button, Table } from "@trussworks/react-uswds"; +import { Table } from "@trussworks/react-uswds"; import classNames from "classnames"; -import React, { ReactNode, useState } from "react"; +import React, { ReactNode } from "react"; import { evaluateValue } from "../../services/evaluateFhirDataService"; +import BuildRow from "@/app/view-data/components/BuildRow"; -interface BuildRowProps { - mappings: PathMappings; - columns: ColumnInfoInput[]; - entry: Element; -} interface TableProps { resources: Element[]; mappings: PathMappings; @@ -46,14 +42,18 @@ const EvaluateTable = ({ {column.columnName} )); - - let tableRows = resources.map((entry, index) => { + let tableRows = resources.map((fhirElement, index) => { + const rowValues = columns.map((column) => + evaluateRowData(column, mappings, fhirElement), + ); return ( + everythingElse, + )} + rowValues={rowValues} /> ); }); @@ -77,70 +77,23 @@ const EvaluateTable = ({ ); }; -/** - * Builds a row for a table based on provided columns, mappings, and entry data. - * @param props - The properties object containing columns, mappings, and entry data. - * @param props.columns - An array of column objects defining the structure of the row. - * @param props.mappings - An object containing mappings for column data. - * @param props.entry - The data entry object for the row. - * @returns - The JSX element representing the constructed row. - */ -const BuildRow: React.FC = ({ - columns, - mappings, - entry, -}: BuildRowProps) => { - const [hiddenComment, setHiddenComment] = useState(true); - - let hiddenRows: React.JSX.Element[] = []; - let rowCells = columns.map((column, index) => { - let rowCellData: ReactNode; - if (column?.value) { - rowCellData = column.value; - } else if (column?.infoPath) { - rowCellData = evaluateValue(entry, mappings[column.infoPath]); - } - if (rowCellData && column.applyToValue) { - rowCellData = column.applyToValue(rowCellData); - } else if (!rowCellData) { - rowCellData = No data; - } else if (column.hiddenBaseText) { - hiddenRows.push( - - - {rowCellData} - - , - ); - rowCellData = ( - - ); - } - return ( - - {rowCellData} - - ); - }); +const evaluateRowData = ( + column: ColumnInfoInput, + mappings: PathMappings, + fhirElement: Element, +) => { + let rowData: ReactNode; + if (column?.value) { + rowData = column.value; + } else if (column?.infoPath) { + rowData = evaluateValue(fhirElement, mappings[column.infoPath]); + } - if (hiddenRows) { - return ( - - {rowCells} - {...hiddenRows} - - ); - } else { - return {rowCells}; + if (rowData && column.applyToValue) { + rowData = column.applyToValue(rowData); } + + return rowData; }; export default EvaluateTable; diff --git a/containers/ecr-viewer/src/app/view-data/components/ExpandCollapseButtons.tsx b/containers/ecr-viewer/src/app/view-data/components/ExpandCollapseButtons.tsx index 39699d98f5..c0d5265272 100644 --- a/containers/ecr-viewer/src/app/view-data/components/ExpandCollapseButtons.tsx +++ b/containers/ecr-viewer/src/app/view-data/components/ExpandCollapseButtons.tsx @@ -1,3 +1,4 @@ +"use client"; import React from "react"; import { Button } from "@trussworks/react-uswds"; diff --git a/containers/ecr-viewer/src/app/view-data/components/Metric.tsx b/containers/ecr-viewer/src/app/view-data/components/Metric.tsx new file mode 100644 index 0000000000..5428f30fe3 --- /dev/null +++ b/containers/ecr-viewer/src/app/view-data/components/Metric.tsx @@ -0,0 +1,28 @@ +"use client"; +import { useEffect } from "react"; +import { metrics } from "@/app/view-data/component-utils"; + +const basePath = process.env.NODE_ENV === "production" ? "/ecr-viewer" : ""; + +/** + * Empty component used to track metrics + * @param props - Properties for metrics + * @param props.fhirId - FhirId of the page visited + * @returns - An empty tag + */ +const Metric = ({ fhirId }: { fhirId: string }) => { + useEffect(() => { + const startTime = performance.now(); + window.addEventListener("beforeunload", async function (_e) { + await metrics(basePath, { + startTime: startTime, + endTime: performance.now(), + fhirId: `${fhirId}`, + }); + }); + }); + + return <>; +}; + +export default Metric; diff --git a/containers/ecr-viewer/src/app/view-data/components/SideNav.tsx b/containers/ecr-viewer/src/app/view-data/components/SideNav.tsx index 66cb48d06f..cbf514595f 100644 --- a/containers/ecr-viewer/src/app/view-data/components/SideNav.tsx +++ b/containers/ecr-viewer/src/app/view-data/components/SideNav.tsx @@ -1,3 +1,4 @@ +"use client"; import React, { useState, useEffect } from "react"; import { SideNav as UswdsSideNav } from "@trussworks/react-uswds"; import { formatString } from "@/app/services/formatService"; diff --git a/containers/ecr-viewer/src/app/view-data/components/common.tsx b/containers/ecr-viewer/src/app/view-data/components/common.tsx index 746c9d8e79..fb9a13f122 100644 --- a/containers/ecr-viewer/src/app/view-data/components/common.tsx +++ b/containers/ecr-viewer/src/app/view-data/components/common.tsx @@ -4,9 +4,7 @@ import { } from "@/app/services/evaluateFhirDataService"; import EvaluateTable from "@/app/view-data/components/EvaluateTable"; import { - TableRow, formatName, - formatTablesToJSON, formatVitals, toSentenceCase, formatDate, @@ -32,6 +30,12 @@ import { import { evaluate } from "@/app/view-data/utils/evaluate"; import parse from "html-react-parser"; import { DisplayDataProps } from "@/app/DataDisplay"; +import { + formatTablesToJSON, + TableRow, +} from "@/app/services/formatTablesToJSON"; +import { JSDOM } from "jsdom"; +import DOMPurify from "dompurify"; /** * Returns a table displaying administered medication information. @@ -507,12 +511,18 @@ export const evaluateClinicalData = ( fhirBundle: Bundle, mappings: PathMappings, ) => { + const htmlString = evaluate( + fhirBundle, + mappings["historyOfPresentIllness"], + )[0]?.div; + const window = new JSDOM("").window; + const purify = DOMPurify(window); + const cleanMiscNotes = purify.sanitize(htmlString); + const clinicalNotes: DisplayDataProps[] = [ { title: "Miscellaneous Notes", - value: parse( - evaluate(fhirBundle, mappings["historyOfPresentIllness"])[0]?.div || "", - ), + value: parse(cleanMiscNotes), }, ]; diff --git a/containers/ecr-viewer/src/app/view-data/error.tsx b/containers/ecr-viewer/src/app/view-data/error.tsx new file mode 100644 index 0000000000..e90f0df716 --- /dev/null +++ b/containers/ecr-viewer/src/app/view-data/error.tsx @@ -0,0 +1,26 @@ +"use client"; // Error components must be Client Components + +import { useEffect } from "react"; + +/** + * Renders an Error component to display when an error occurs. + * @param props - The props object. + * @param props.error - The error object, possibly with a digest. + * @returns - Returns the JSX element for the Error component. + */ +export default function Error({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); + + return ( +
    +

    Something went wrong!

    +
    + ); +} diff --git a/containers/ecr-viewer/src/app/view-data/loading.tsx b/containers/ecr-viewer/src/app/view-data/loading.tsx new file mode 100644 index 0000000000..aa435079b4 --- /dev/null +++ b/containers/ecr-viewer/src/app/view-data/loading.tsx @@ -0,0 +1,8 @@ +/** + * Loading page for view-data + * @returns - loading page element + */ +export default function Loading() { + // You can add any UI inside Loading, including a Skeleton. + return
    Loading ....
    ; +} diff --git a/containers/ecr-viewer/src/app/view-data/page.tsx b/containers/ecr-viewer/src/app/view-data/page.tsx index 9c1684debe..071261e0c4 100644 --- a/containers/ecr-viewer/src/app/view-data/page.tsx +++ b/containers/ecr-viewer/src/app/view-data/page.tsx @@ -1,156 +1,97 @@ -"use client"; -import AccordionContainer from "@/app/view-data/components/AccordionContainer"; -import { useSearchParams } from "next/navigation"; -import React, { useEffect, useState } from "react"; -import { Bundle } from "fhir/r4"; -import { PathMappings } from "../utils"; -import SideNav from "./components/SideNav"; -import { processSnomedCode } from "./service"; -import { Grid, GridContainer } from "@trussworks/react-uswds"; -import { ExpandCollapseButtons } from "@/app/view-data/components/ExpandCollapseButtons"; -import EcrSummary from "./components/EcrSummary"; +import { processSnomedCode } from "@/app/view-data/service"; +import { loadYamlConfig } from "@/app/api/services/utils"; +import { getEcrData } from "@/app/api/services/ecrDataService"; +import SideNav from "@/app/view-data/components/SideNav"; +import EcrSummary from "@/app/view-data/components/EcrSummary"; import { - evaluateEcrSummaryPatientDetails, - evaluateEcrSummaryEncounterDetails, evaluateEcrSummaryAboutTheConditionDetails, -} from "../services/ecrSummaryService"; -import { metrics } from "./component-utils"; - -// string constants to match with possible .env values -const basePath = process.env.NODE_ENV === "production" ? "/ecr-viewer" : ""; + evaluateEcrSummaryEncounterDetails, + evaluateEcrSummaryPatientDetails, +} from "@/app/services/ecrSummaryService"; +import { Grid, GridContainer } from "@trussworks/react-uswds"; +import { ExpandCollapseButtons } from "@/app/view-data/components/ExpandCollapseButtons"; +import AccordionContainer from "@/app/view-data/components/AccordionContainer"; +import React from "react"; +import Metric from "@/app/view-data/components/Metric"; /** - * Functional component for rendering the eCR Viewer page. + * Functional component for rendering a page based on provided search parameters. + * @param props - Component props. + * @param props.searchParams - Search parameters object. * @returns The main eCR Viewer JSX component. */ -const ECRViewerPage: React.FC = () => { - const [fhirBundle, setFhirBundle] = useState(); - const [mappings, setMappings] = useState({}); - const [errors, setErrors] = useState(); - const searchParams = useSearchParams(); - const fhirId = searchParams ? searchParams.get("id") ?? "" : ""; - const snomedCode = searchParams ? searchParams.get("snomed-code") ?? "" : ""; +export default async function Page({ + searchParams, +}: Readonly<{ searchParams: { [key: string]: string | undefined } }>) { + const fhirId = searchParams["id"] ?? ""; + const snomedCode = searchParams["snomed-code"] ?? ""; + processSnomedCode(snomedCode); + const fhirBundle = await getEcrData(fhirId); + const mappings = loadYamlConfig(); - type ApiResponse = { - fhirBundle: Bundle; - fhirPathMappings: PathMappings; - }; - - useEffect(() => { - const startTime = performance.now(); - window.addEventListener("beforeunload", function (_e) { - metrics(basePath, { - startTime: startTime, - endTime: performance.now(), - fhirId: `${fhirId}`, - }); - }); - const fetchData = async () => { - try { - const response = await fetch(`${basePath}/api/fhir-data?id=${fhirId}`); - if (!response.ok) { - if (response.status == 404) { - throw new Error( - "Sorry, we couldn't find this eCR ID. Please try again with a different ID.", - ); - } else { - const errorData = response.statusText; - throw new Error(errorData || "Internal Server Error"); - } - } else { - const bundle: ApiResponse = await response.json(); - processSnomedCode(snomedCode); - setFhirBundle(bundle.fhirBundle); - setMappings(bundle.fhirPathMappings); - } - } catch (error: any) { - setErrors(error); - console.error("Error fetching data:", error); - } - }; - fetchData(); - }, []); - - if (errors) { - return ( + return ( +
    +
    -
    -          {`${errors}`}
    -        
    -
    - ); - } else if (fhirBundle && mappings) { - return ( -
    -
    -
    -
    -
    - -
    -
    -
    -

    - eCR Summary -

    - -
    - - - -

    - eCR Document -

    -
    - - .usa-accordion__button"} - accordionSelector={ - ".info-container > .usa-accordion__content" - } - expandButtonText={"Expand all sections"} - collapseButtonText={"Collapse all sections"} - /> - +
    +
    +
    + +
    +
    +
    +

    + eCR Summary +

    + +
    + + + +

    + eCR Document +

    +
    + + .usa-accordion__button"} + accordionSelector={ + ".info-container > .usa-accordion__content" + } + expandButtonText={"Expand all sections"} + collapseButtonText={"Collapse all sections"} + /> -
    - -
    + + +
    -
    - ); - } else { - return ( -
    -

    Loading...

    - ); - } -}; - -export default ECRViewerPage; +
    + ); +}