diff --git a/docker-compose.yml b/docker-compose.yml index b215b137d..cb03956f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,8 @@ services: - POSTGRES_PASSWORD=pleaseChange # default postgres password that should be changed for security. volumes: - ./postgres-data:/var/lib/postgresql/data/pgdata - #ports: - # - "5432:5432" + # ports: + # - "5432:5432" # Uncomment the above lines to enable access to the PostgreSQL server # from the host machine. # Web service runs Node diff --git a/package-lock.json b/package-lock.json index f3f60960b..fa2f0736d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,8 @@ "moment-timezone": "~0.5.34", "morgan": "~1.9.0", "multer": "~1.3.1", + "ngraph.graph": "~20.0.0", + "ngraph.path": "~1.4.0", "node-polyfill-webpack-plugin": "~1.1.4", "nodemailer": "~6.6.1", "package-lock": "~1.0.3", @@ -57,6 +59,7 @@ "query-string": "~7.0.1", "rc-slider": "~8.6.6", "react": "~17.0.2", + "react-bootstrap": "~2.2.3", "react-dom": "~17.0.2", "react-dropzone": "~12.0.4", "react-intl": "~5.22.0", @@ -2527,6 +2530,26 @@ "pick-by-alias": "^1.2.0" } }, + "node_modules/@popperjs/core": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", + "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.1.2.tgz", + "integrity": "sha512-amXY11ImpokvkTMeKRHjsSsG7v1yzzs6yeqArCyBIk60J3Yhgxwx9Cah+Uu/804ATFwqzN22AXIo7SdtIaMP+g==", + "dependencies": { + "@babel/runtime": "^7.6.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1" + } + }, "node_modules/@redux-devtools/core": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/@redux-devtools/core/-/core-3.11.0.tgz", @@ -2590,6 +2613,54 @@ "redux": "^3.4.0 || ^4.0.0" } }, + "node_modules/@restart/hooks": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.7.tgz", + "integrity": "sha512-ZbjlEHcG+FQtpDPHd7i4FzNNvJf2enAwZfJbpM8CW7BhmOAbsHpZe3tsHwfQUrBuyrxWqPYp2x5UMnilWcY22A==", + "dependencies": { + "dequal": "^2.0.2" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.2.0.tgz", + "integrity": "sha512-oIh2t3tG8drZtZ9SlaV5CY6wGsUViHk8ZajjhcI+74IQHyWy+AnxDv8rJR5wVgsgcgrPBUvGNkC1AEdcGNPaLQ==", + "dependencies": { + "@babel/runtime": "^7.13.16", + "@popperjs/core": "^2.10.1", + "@react-aria/ssr": "^3.0.1", + "@restart/hooks": "^0.4.0", + "@types/warning": "^3.0.0", + "dequal": "^2.0.2", + "dom-helpers": "^5.2.0", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/@restart/ui/node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", @@ -2888,6 +2959,11 @@ "@types/node": "*" } }, + "node_modules/@types/invariant": { + "version": "2.2.35", + "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.35.tgz", + "integrity": "sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg==" + }, "node_modules/@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", @@ -3078,6 +3154,11 @@ "@types/node": "*" } }, + "node_modules/@types/warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", + "integrity": "sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=" + }, "node_modules/@types/ws": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz", @@ -5010,9 +5091,9 @@ "integrity": "sha1-ZqDmQBGBbjcZaCj9yMjBRzEshjQ=" }, "node_modules/classnames": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", - "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" }, "node_modules/clean-stack": { "version": "2.2.0", @@ -6053,6 +6134,14 @@ "node": ">= 0.6" } }, + "node_modules/dequal": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.2.tgz", + "integrity": "sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==", + "engines": { + "node": ">=6" + } + }, "node_modules/des.js": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", @@ -10430,6 +10519,24 @@ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=" }, + "node_modules/ngraph.events": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.2.1.tgz", + "integrity": "sha512-D4C+nXH/RFxioGXQdHu8ELDtC6EaCiNsZtih0IvyGN81OZSUby4jXoJ5+RNWasfsd0FnKxxpAROyUMzw64QNsw==" + }, + "node_modules/ngraph.graph": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.0.0.tgz", + "integrity": "sha512-tJqmik6U5geNDSbmTSwm4R6coTMDbkfFFHD8wdeSJtKU/cxIWFsKtXuwMva/wTk6tQQl1C2//lrzmwfPJXAXHw==", + "dependencies": { + "ngraph.events": "^1.2.1" + } + }, + "node_modules/ngraph.path": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ngraph.path/-/ngraph.path-1.4.0.tgz", + "integrity": "sha512-yJZay4tP0wcjqkkf8zlMQ/T+JOgU+EWfdE4w4TG8OS94B12J/+Z44UOYxVJErE8E6/wFunX1hMZEB1/GHsBYHg==" + }, "node_modules/nise": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.0.tgz", @@ -11567,6 +11674,26 @@ "react-is": "^16.8.1" } }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/prop-types-extra/node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/prop-types/node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11909,6 +12036,83 @@ "node": ">=0.10.0" } }, + "node_modules/react-bootstrap": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.2.3.tgz", + "integrity": "sha512-gXsAEBdDUHnOpJ2C+DDQ4mFt7tN6u6qWnTH3tqiE9jUvV6gGY8uHFp0iGBsM+yjrBwmR6bqCBFh8Z82aQj1LSw==", + "dependencies": { + "@babel/runtime": "^7.17.2", + "@restart/hooks": "^0.4.6", + "@restart/ui": "^1.2.0", + "@types/invariant": "^2.2.35", + "@types/prop-types": "^15.7.4", + "@types/react": ">=16.14.8", + "@types/react-transition-group": "^4.4.4", + "@types/warning": "^3.0.0", + "classnames": "^2.3.1", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.2", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/react-bootstrap/node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/react-bootstrap/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-bootstrap/node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react-bootstrap/node_modules/react-transition-group": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", + "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react-bootstrap/node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -14472,6 +14676,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -17776,6 +17994,19 @@ "pick-by-alias": "^1.2.0" } }, + "@popperjs/core": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", + "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==" + }, + "@react-aria/ssr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.1.2.tgz", + "integrity": "sha512-amXY11ImpokvkTMeKRHjsSsG7v1yzzs6yeqArCyBIk60J3Yhgxwx9Cah+Uu/804ATFwqzN22AXIo7SdtIaMP+g==", + "requires": { + "@babel/runtime": "^7.6.2" + } + }, "@redux-devtools/core": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/@redux-devtools/core/-/core-3.11.0.tgz", @@ -17827,6 +18058,49 @@ "lodash": "^4.17.21" } }, + "@restart/hooks": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.7.tgz", + "integrity": "sha512-ZbjlEHcG+FQtpDPHd7i4FzNNvJf2enAwZfJbpM8CW7BhmOAbsHpZe3tsHwfQUrBuyrxWqPYp2x5UMnilWcY22A==", + "requires": { + "dequal": "^2.0.2" + } + }, + "@restart/ui": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.2.0.tgz", + "integrity": "sha512-oIh2t3tG8drZtZ9SlaV5CY6wGsUViHk8ZajjhcI+74IQHyWy+AnxDv8rJR5wVgsgcgrPBUvGNkC1AEdcGNPaLQ==", + "requires": { + "@babel/runtime": "^7.13.16", + "@popperjs/core": "^2.10.1", + "@react-aria/ssr": "^3.0.1", + "@restart/hooks": "^0.4.0", + "@types/warning": "^3.0.0", + "dequal": "^2.0.2", + "dom-helpers": "^5.2.0", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "dependencies": { + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + } + } + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", @@ -18102,6 +18376,11 @@ "@types/node": "*" } }, + "@types/invariant": { + "version": "2.2.35", + "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.35.tgz", + "integrity": "sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg==" + }, "@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", @@ -18291,6 +18570,11 @@ "@types/node": "*" } }, + "@types/warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", + "integrity": "sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=" + }, "@types/ws": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz", @@ -19768,9 +20052,9 @@ "integrity": "sha1-ZqDmQBGBbjcZaCj9yMjBRzEshjQ=" }, "classnames": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", - "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" }, "clean-stack": { "version": "2.2.0", @@ -20646,6 +20930,11 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" }, + "dequal": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.2.tgz", + "integrity": "sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==" + }, "des.js": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", @@ -24956,6 +25245,25 @@ } } }, + "prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "requires": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "dependencies": { + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + } + } + }, "protocol-buffers-schema": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", @@ -25248,6 +25556,74 @@ } } }, + "react-bootstrap": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.2.3.tgz", + "integrity": "sha512-gXsAEBdDUHnOpJ2C+DDQ4mFt7tN6u6qWnTH3tqiE9jUvV6gGY8uHFp0iGBsM+yjrBwmR6bqCBFh8Z82aQj1LSw==", + "requires": { + "@babel/runtime": "^7.17.2", + "@restart/hooks": "^0.4.6", + "@restart/ui": "^1.2.0", + "@types/invariant": "^2.2.35", + "@types/prop-types": "^15.7.4", + "@types/react": ">=16.14.8", + "@types/react-transition-group": "^4.4.4", + "@types/warning": "^3.0.0", + "classnames": "^2.3.1", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.2", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "dependencies": { + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "react-transition-group": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", + "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + } + } + }, "react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -27279,6 +27655,17 @@ "which-boxed-primitive": "^1.0.2" } }, + "uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "requires": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + } + }, "undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index 7467f73cd..7e4e0d9be 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "refreshAllReadingViews": "node -e 'require(\"./src/server/services/refreshAllReadingViews\").refreshAllReadingViews()'", "refreshDailyReadingViews": "node -e 'require(\"./src/server/services/refreshReadingViews\").refreshReadingViews()'", "refreshHourlyReadingViews": "node -e 'require(\"./src/server/services/refreshHourlyReadingViews\").refreshHourlyReadingViews()'", + "updateCikAndViews": "node -e 'require(\"./src/server/services/graph/redoCik.js\").updateCikAndViews()'", "obvius:showConfigfiles": "node ./src/server/services/obvius/showConfigfiles.js", "obvius:purgeConfigfiles": "node ./src/server/services/obvius/purgeConfigfiles.js", "generateFourDayTestingData": "node -e 'require(\"./src/server/data/automatedTestingData\").generateFourDayTestingData()'", @@ -40,9 +41,7 @@ "generateSineSquaredTestingData": "node -e 'require(\"./src/server/data/automatedTestingData\").generateSineSquaredTestingData(2.5)'", "generateCosineSquaredTestingData": "node -e 'require(\"./src/server/data/automatedTestingData\").generateCosineSquaredTestingData(2.5)'", "generateTestingData": "node -e 'require(\"./src/server/data/automatedTestingData\").generateTestingData()'", - "testData": "./src/scripts/testData.sh", - "insertSpecialUnitsAndConversions": "node -e 'require(\"./src/server/data/automatedTestingData.js\").insertSpecialUnitsAndConversions()'", - "insertSpecialMeters": "node -e 'require(\"./src/server/data/automatedTestingData.js\").insertSpecialMeters()'" + "testData": "node -e 'require(\"./src/server/data/automatedTestingData.js\").insertSpecialUnitsConversionsMeters()'" }, "nodemonConfig": { "watch": [ @@ -113,6 +112,7 @@ "query-string": "~7.0.1", "rc-slider": "~8.6.6", "react": "~17.0.2", + "react-bootstrap": "~2.2.3", "react-dom": "~17.0.2", "react-dropzone": "~12.0.4", "react-intl": "~5.22.0", diff --git a/src/client/app/actions/graph.ts b/src/client/app/actions/graph.ts index c41b8cd65..bd78fcfb7 100644 --- a/src/client/app/actions/graph.ts +++ b/src/client/app/actions/graph.ts @@ -41,6 +41,10 @@ export function updateBarDuration(barDuration: moment.Duration): t.UpdateBarDura return { type: ActionType.UpdateBarDuration, barDuration }; } +export function updateLineGraphRate(lineGraphRate: t.LineGraphRate) { + return { type: ActionType.UpdateLineGraphRate, lineGraphRate } +} + export function setHotlinked(hotlinked: boolean): t.SetHotlinked { return { type: ActionType.SetHotlinked, hotlinked }; } diff --git a/src/client/app/actions/groups.ts b/src/client/app/actions/groups.ts index 942f165ee..e3b987c99 100644 --- a/src/client/app/actions/groups.ts +++ b/src/client/app/actions/groups.ts @@ -24,7 +24,7 @@ function requestGroupChildren(groupID: number): t.RequestGroupChildrenAction { return { type: ActionType.RequestGroupChildren, groupID }; } -function receiveGroupChildren(groupID: number, data: {meters: number[], groups: number[], deepMeters: number[]}): t.ReceiveGroupChildrenAction { +function receiveGroupChildren(groupID: number, data: { meters: number[], groups: number[], deepMeters: number[] }): t.ReceiveGroupChildrenAction { return { type: ActionType.ReceiveGroupChildren, groupID, data }; } @@ -321,7 +321,8 @@ export function submitGroupInEditingIfNeeded() { gps: rawGroup.gps, displayable: rawGroup.displayable, note: rawGroup.note, - area: rawGroup.area + area: rawGroup.area, + defaultGraphicUnit: rawGroup.defaultGraphicUnit }; if (creatingNewGroup(getState())) { return dispatch(submitNewGroup(group)); diff --git a/src/client/app/actions/meters.ts b/src/client/app/actions/meters.ts index 9dc6dfd5c..53b12a7dd 100644 --- a/src/client/app/actions/meters.ts +++ b/src/client/app/actions/meters.ts @@ -1,97 +1,117 @@ /* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import * as _ from 'lodash'; -import { ActionType, Dispatch, GetState, Thunk } from '../types/redux/actions'; -import { MeterMetadata } from '../types/redux/meters'; -import { State } from '../types/redux/state'; +import { ActionType, Thunk, Dispatch, GetState } from '../types/redux/actions'; +import { showSuccessNotification, showErrorNotification } from '../utils/notifications'; +import translate from '../utils/translate'; import * as t from '../types/redux/meters'; -import { NamedIDItem } from '../types/items'; import { metersApi } from '../utils/api'; -import { showErrorNotification } from '../utils/notifications'; -import translate from '../utils/translate'; export function requestMetersDetails(): t.RequestMetersDetailsAction { return { type: ActionType.RequestMetersDetails }; } -export function receiveMetersDetails(data: NamedIDItem[]): t.ReceiveMetersDetailsAction { +export function receiveMetersDetails(data: t.MeterData[]): t.ReceiveMetersDetailsAction { return { type: ActionType.ReceiveMetersDetails, data }; } -export function changeDisplayedMeters(meters: number[]): t.ChangeDisplayedMetersAction { - return { type: ActionType.ChangeDisplayedMeters, selectedMeters: meters}; +export function fetchMetersDetails(): Thunk { + return async (dispatch: Dispatch, getState: GetState) => { + // ensure a fetch is not currently happening + if (!getState().meters.isFetching) + { + // set isFetching to true + dispatch(requestMetersDetails()); + // attempt to retrieve meters details from database + const meters = await metersApi.getMetersDetails(); + // update the state with the meters details and set isFetching to false + dispatch(receiveMetersDetails(meters)); + // If this is the first fetch, inform the store that the first fetch has been made + if (!getState().meters.hasBeenFetchedOnce) + { + dispatch(confirmMetersFetchedOnce()); + } + } + } } -export function editMeterDetails(meter: MeterMetadata): t.EditMeterDetailsAction { - return { type: ActionType.EditMeterDetails, meter }; +export function changeDisplayedMeters(meters: number[]): t.ChangeDisplayedMetersAction { + return { type: ActionType.ChangeDisplayedMeters, selectedMeters: meters }; } -export function submitMeterEdits(meter: number): t.SubmitEditedMeterAction { - return { type: ActionType.SubmitEditedMeter, meter }; +// Pushes meterId onto submitting meters state array +export function submitMeterEdits(meterId: number): t.SubmitEditedMeterAction { + return { type: ActionType.SubmitEditedMeter, meterId }; } -export function confirmMeterEdits(meter: number): t.ConfirmEditedMeterAction { - return { type: ActionType.ConfirmEditedMeter, meter}; +export function confirmMeterEdits(editedMeter: t.MeterData): t.ConfirmEditedMeterAction { + return { type: ActionType.ConfirmEditedMeter, editedMeter }; } -export function fetchMetersDetails(): Thunk { - return async (dispatch: Dispatch) => { - dispatch(requestMetersDetails()); - const metersDetails = await metersApi.details(); - dispatch(receiveMetersDetails(metersDetails)); - }; +export function deleteSubmittedMeter(meterId: number): t.DeleteSubmittedMeterAction { + return {type: ActionType.DeleteSubmittedMeter, meterId} } -export function submitEditedMeters(): Thunk { - return async (dispatch: Dispatch, getState: GetState) => { - Object.keys(getState().meters.editedMeters).forEach(meterIdS => { - const meterId = parseInt(meterIdS); - if (getState().meters.submitting.indexOf(meterId) === -1) { - dispatch(submitEditedMeter(meterId)); - } - }); - }; +export function confirmMetersFetchedOnce(): t.ConfirmMetersFetchedOnceAction { + return { type: ActionType.ConfirmMetersFetchedOnce }; } -export function submitEditedMeter(meterId: number): Thunk { - return async (dispatch: Dispatch, getState: GetState) => { - const submittingMeter = getState().meters.editedMeters[meterId]; - dispatch(submitMeterEdits(meterId)); - try { - await metersApi.edit(submittingMeter); - dispatch(confirmMeterEdits(meterId)); - } catch (err) { - showErrorNotification(translate('failed.to.edit.meter')); +// Fetch the meters details from the database if they have not already been fetched once +export function fetchMetersDetailsIfNeeded(): Thunk { + return (dispatch: Dispatch, getState: GetState) => { + // If meters have not been fetched once, return the fetchMeterDetails function + if (!getState().meters.hasBeenFetchedOnce) + { + return dispatch(fetchMetersDetails()); } + // If meters have already been fetched, return a resolved promise + return Promise.resolve(); }; } -/** - * Remove all the meters in editing without submitting them - */ -export function confirmEditedMeters(): Thunk { +export function submitEditedMeter(editedMeter: t.MeterData): Thunk { return async (dispatch: Dispatch, getState: GetState) => { - Object.keys(getState().meters.editedMeters).forEach(meterIdS => { - const meterId = parseInt(meterIdS); - dispatch(confirmMeterEdits(meterId)); - }); - } -} + // check if meterData is already submitting (indexOf returns -1 if item does not exist in array) + if (getState().meters.submitting.indexOf(editedMeter.id) === -1) { + // Inform the store we are about to edit the passed in meter + // Pushes meterId of the meterData to submit onto the submitting state array + dispatch(submitMeterEdits(editedMeter.id)); -/** - * @param {State} state - */ -function shouldFetchMetersDetails(state: State): boolean { - return !state.meters.isFetching && _.size(state.meters.byMeterID) === 0; -} - -export function fetchMetersDetailsIfNeeded(alwaysFetch?: boolean): Thunk { - return (dispatch: Dispatch, getState: GetState) => { - if (alwaysFetch || shouldFetchMetersDetails(getState())) { - return dispatch(fetchMetersDetails()); + // Attempt to edit the meter in the database + try { + // posts the edited meterData to the meters API + await metersApi.edit(editedMeter); + // Clear meter Id from submitting state array + dispatch(deleteSubmittedMeter(editedMeter.id)); + // Update the store with our new edits + dispatch(confirmMeterEdits(editedMeter)); + // Success! + showSuccessNotification(translate('meter.successfully.edited.meter')); + } catch (err) { + // Failure! ): + showErrorNotification(translate('meter.failed.to.edit.meter')); + // Clear our changes from to the submitting meters state + // We must do this in case fetch failed to keep the store in sync with the database + dispatch(deleteSubmittedMeter(editedMeter.id)); + } } - return Promise.resolve(); }; } + +// Add meter to database +export function addMeter(meter: t.MeterData): Thunk { + return async (dispatch: Dispatch) => { + try { + // Attempt to add meter to database + await metersApi.addMeter(meter); + // Update the meters state from the database on a successful call + // In the future, getting rid of this database fetch and updating the store on a successful API call would make the page faster + // However, since the database currently assigns the id to the MeterData + dispatch(fetchMetersDetails()); + showSuccessNotification(translate('meter.successfully.create.meter')); + } catch (err) { + showErrorNotification(translate('meter.failed.to.create.meter')); + } + } +} \ No newline at end of file diff --git a/src/client/app/actions/units.ts b/src/client/app/actions/units.ts index 32230c60f..3b715da63 100644 --- a/src/client/app/actions/units.ts +++ b/src/client/app/actions/units.ts @@ -3,7 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { ActionType, Thunk, Dispatch, GetState } from '../types/redux/actions'; -import { State } from '../types/redux/state'; +import { showSuccessNotification, showErrorNotification } from '../utils/notifications'; +import translate from '../utils/translate'; import * as t from '../types/redux/units'; import { unitsApi } from '../utils/api'; @@ -16,22 +17,95 @@ export function receiveUnitsDetails(data: t.UnitData[]): t.ReceiveUnitsDetailsAc } export function fetchUnitsDetails(): Thunk { - return async (dispatch: Dispatch) => { - dispatch(requestUnitsDetails()); - const units = await unitsApi.getUnitsDetails(); - dispatch(receiveUnitsDetails(units)); + return async (dispatch: Dispatch, getState: GetState) => { + // ensure a fetch is not currently happening + if (!getState().units.isFetching) { + // set isFetching to true + dispatch(requestUnitsDetails()); + // attempt to retrieve units details from database + const units = await unitsApi.getUnitsDetails(); + // update the state with the units details and set isFetching to false + dispatch(receiveUnitsDetails(units)); + // If this is the first fetch, inform the store that the first fetch has been made + if (!getState().units.hasBeenFetchedOnce) { + dispatch(confirmUnitsFetchedOnce()); + } + } } } -function shouldFetchUnitsDetails(state: State): boolean { - return !state.units.isFetching; +export function changeDisplayedUnits(units: number[]): t.ChangeDisplayedUnitsAction { + return { type: ActionType.ChangeDisplayedUnits, selectedUnits: units }; +} + +// Pushes unitId onto submitting units state array +export function submitUnitEdits(unitId: number): t.SubmitEditedUnitAction { + return { type: ActionType.SubmitEditedUnit, unitId }; +} + +export function confirmUnitEdits(editedUnit: t.UnitData): t.ConfirmEditedUnitAction { + return { type: ActionType.ConfirmEditedUnit, editedUnit }; +} + +export function deleteSubmittedUnit(unitId: number): t.DeleteSubmittedUnitAction { + return { type: ActionType.DeleteSubmittedUnit, unitId } +} + +export function confirmUnitsFetchedOnce(): t.ConfirmUnitsFetchedOnceAction { + return { type: ActionType.ConfirmUnitsFetchedOnce }; } +// Fetch the units details from the database if they have not already been fetched once export function fetchUnitsDetailsIfNeeded(): Thunk { return (dispatch: Dispatch, getState: GetState) => { - if (shouldFetchUnitsDetails(getState())) { + // If units have not been fetched once, return the fetchUnitDetails function + if (!getState().units.hasBeenFetchedOnce) { return dispatch(fetchUnitsDetails()); } + // If units have already been fetched, return a resolved promise return Promise.resolve(); }; } + +export function submitEditedUnit(editedUnit: t.UnitData): Thunk { + return async (dispatch: Dispatch, getState: GetState) => { + // check if unitData is already submitting (indexOf returns -1 if item does not exist in array) + if (getState().units.submitting.indexOf(editedUnit.id) === -1) { + // Inform the store we are about to edit the passed in unit + // Pushes unitId of the unitData to submit onto the submitting state array + dispatch(submitUnitEdits(editedUnit.id)); + + // Attempt to edit the unit in the database + try { + // posts the edited unitData to the units API + await unitsApi.edit(editedUnit); + // Update the store with our new edits + dispatch(confirmUnitEdits(editedUnit)); + // Success! + showSuccessNotification(translate('unit.successfully.edited.unit')); + } catch (err) { + // Failure! ): + showErrorNotification(translate('unit.failed.to.edit.unit')); + } + // Clear unit Id from submitting state array + dispatch(deleteSubmittedUnit(editedUnit.id)); + } + }; +} + +// Add unit to database +export function addUnit(unit: t.UnitData): Thunk { + return async (dispatch: Dispatch) => { + try { + // Attempt to add unit to database + await unitsApi.addUnit(unit); + // Update the units state from the database on a successful call + // In the future, getting rid of this database fetch and updating the store on a successful API call would make the page faster + // However, since the database currently assigns the id to the UnitData we need to fetch + dispatch(fetchUnitsDetails()); + showSuccessNotification(translate('unit.successfully.create.unit')); + } catch (err) { + showErrorNotification(translate('unit.failed.to.create.unit')); + } + } +} \ No newline at end of file diff --git a/src/client/app/components/ChartDataSelectComponent.tsx b/src/client/app/components/ChartDataSelectComponent.tsx index a6affd6e4..e505527ab 100644 --- a/src/client/app/components/ChartDataSelectComponent.tsx +++ b/src/client/app/components/ChartDataSelectComponent.tsx @@ -16,10 +16,16 @@ import { CartesianPoint, Dimensions, normalizeImageDimensions, calculateScaleFromEndpoints, itemDisplayableOnMap, itemMapInfoOk, gpsToUserGrid } from '../utils/calibration'; -import { changeSelectedGroups, changeSelectedMeters, changeSelectedUnit } from '../actions/graph'; +import { + changeSelectedGroups, changeSelectedMeters, changeSelectedUnit, updateSelectedMeters, + updateSelectedGroups, updateSelectedUnit +} from '../actions/graph'; import { DisplayableType, UnitData, UnitType } from '../types/redux/units' -import { metersInGroup, setIntersect, unitsCompatibleWithMeters } from '../utils/determineCompatibleUnits'; +import { metersInGroup, unitsCompatibleWithMeters } from '../utils/determineCompatibleUnits'; import { Dispatch } from '../types/redux/actions'; +import { UnitsState } from '../types/redux/units'; +import { MetersState } from 'types/redux/meters'; +import { GroupsState } from 'types/redux/groups'; /** * A component which allows the user to select which data should be displayed on the chart. @@ -47,10 +53,8 @@ export default function ChartDataSelectComponent() { const allGroups = state.groups.byGroupID; // Map information about meters, groups and units into a format the component can display. - const sortedMeters = _.sortBy(_.values(allMeters).map(meter => - ({ value: meter.id, label: meter.name.trim(), isDisabled: false } as SelectOption)), 'label'); - const sortedGroups = _.sortBy(_.values(allGroups).map(group => - ({ value: group.id, label: group.name.trim(), isDisabled: false } as SelectOption)), 'label'); + const sortedMeters = getMeterCompatibilityForDropdown(state); + const sortedGroups = getGroupCompatibilityForDropdown(state); const sortedUnits = getUnitCompatibilityForDropdown(state); //Map information about the currently selected meters into a format the component can display. @@ -137,13 +141,7 @@ export default function ChartDataSelectComponent() { // The selectedUnit becomes the unit of the meter selected. Note is should always be set (not -99) since // those meters should not have been visible. The only exception is if there are no selected meters but // then this loop does not run. The loop is assumed to only run once in this case. - // TODO this really should only be for debugging?? - if (state.graph.selectedMeters.length != 1) { - console.log('9000 state.graph.selectedMeters length is not one but ' + state.graph.selectedMeters.length); - } - - // TODO is it possible the unit of the meter is not set in state? Seems not if can select?? - state.graph.selectedUnit = state.meters.byMeterID[meterID].defaultGraphicUnit; + dispatch(changeSelectedUnit(state.meters.byMeterID[meterID].defaultGraphicUnit)); } selectedMeters.push({ // For meters we display the identifier. @@ -152,33 +150,22 @@ export default function ChartDataSelectComponent() { isDisabled: false } as SelectOption) } - // } - // } }); const selectedGroups: SelectOption[] = []; state.graph.selectedGroups.forEach(groupID => { - // TODO For now you cannot graph a group unit you have a graphing unit - if (!(disableGroups.includes(groupID)) && state.graph.selectedUnit != -99) { - // TODO use this code once group has default graphic unit - // if (!(disableGroups.includes(groupID))) { - // // If the selected unit is -99 then there is not graphic unit yet. In this case you can only select a - // // group that has a default graphic unit because that will become the selected unit. This should only - // // happen if no meter or group is yet selected. - // if (state.graph.selectedUnit == -99) { - // // If no unit is set then this should always be the first group (or meter) selected. - // // The selectedUnit becomes the unit of the group selected. Note is should always be set (not -99) since - // // those groups should not have been visible. The only exception is if there are no selected groups but - // // then this loop does not run. The loop is assumed to only run once in this case. - // // TODO this really should only be for debugging?? - // if (state.graph.selectedGroups.length != 1) { - // console.log('9100 state.graph.selectedGroups length is not one but ' + state.graph.selectedGroups.length); - // } - - // // TODO is it possible the unit of the meter is not set in state? Seems not if can select?? - // // TODO group state does not yet have default graphic unit so must wait to do this. - // state.graph.selectedUnit = state.groups.byGroupID[groupID].defaultGraphicUnit; - // } + // Don't include disabled groups. + if (!(disableGroups.includes(groupID))) { + // If the selected unit is -99 then there is no graphic unit yet. In this case you can only select a + // group that has a default graphic unit because that will become the selected unit. This should only + // happen if no meter or group is yet selected. + if (state.graph.selectedUnit == -99) { + // If no unit is set then this should always be the first group (or meter) selected. + // The selectedUnit becomes the unit of the group selected. Note is should always be set (not -99) since + // those groups should not have been visible. The only exception is if there are no selected groups but + // then this loop does not run. The loop is assumed to only run once in this case. + state.graph.selectedUnit = state.groups.byGroupID[groupID].defaultGraphicUnit; + } selectedGroups.push({ // For groups we display the name since no identifier. label: state.groups.byGroupID[groupID] ? state.groups.byGroupID[groupID].name : '', @@ -254,17 +241,20 @@ export default function ChartDataSelectComponent() { selectedOptions={dataProps.selectedUnit} placeholder={intl.formatMessage(messages.selectUnit)} onValuesChange={(newSelectedUnitOptions: SelectOption[]) => { - let newUnit; + // TODO I don't quite understand why the component results in an array of size 2 when updating state + // For now I have hardcoded a fix that allows units to be selected over other units without clicking the x button if (newSelectedUnitOptions.length === 0) { - // The unit was unselected so no new one. Thus, set to -99 since no unit. - // TODO This causes an error by trying to get data before the value is changed again.?? - // TODO need to clear all meters/groups in this case. - // TODO nice if warned user about to clear all meters/groups. - newUnit = -99; - } else { - newUnit = newSelectedUnitOptions[0].value; + // Update the selected meters and groups to empty to avoid graphing errors + // The update selected meters/groups functions are essentially the same as the change functions + // However, they do not attempt to graph. + dispatch(updateSelectedGroups([])); + dispatch(updateSelectedMeters([])); + dispatch(updateSelectedUnit(-99)); } - dispatch(changeSelectedUnit(newUnit)); + else if (newSelectedUnitOptions.length === 1) { dispatch(changeSelectedUnit(newSelectedUnitOptions[0].value)); } + else if (newSelectedUnitOptions.length > 1) { dispatch(changeSelectedUnit(newSelectedUnitOptions[1].value)); } + // This should not happen + else { dispatch(changeSelectedUnit(-99)); } }} /> @@ -278,50 +268,40 @@ export default function ChartDataSelectComponent() { * @param {State} state - current redux state * @return {SelectOption[]} an array of SelectOption */ -export function getUnitCompatibilityForDropdown(state: State) { +function getUnitCompatibilityForDropdown(state: State) { + // Holds all units that are compatible with selected meters/groups const compatibleUnits = new Set(); // Holds all units that are not compatible with selected meters/groups const incompatibleUnits = new Set(); - if (state.graph.selectedUnit === -99) { + + // Holds all selected meters, including those retrieved from groups + const allSelectedMeters = new Set(); + + // Get for all meters + state.graph.selectedMeters.forEach(meter => { + allSelectedMeters.add(meter); + }); + // Get for all groups + state.graph.selectedGroups.forEach(group => { + // Get for all deep meters in group + metersInGroup(group).forEach(meter => { + allSelectedMeters.add(meter); + }); + }); + + if (allSelectedMeters.size == 0) { + // No meters/groups are selected. This includes the case where the selectedUnit is -99. // Every unit is okay/compatible in this case so skip the work needed below. - // Can only show unit types (not meters) and only displayable ones. - // is either all (not logged in as admin) or admin + // Filter the units to be displayed by user status and displayable type getVisibleUnitOrSuffixState(state).forEach(unit => { compatibleUnits.add(unit.id); - }) + }); } else { // Some meter or group is selected - // Holds the units compatible with the meters/groups selected. - // The first meter or group processed is different since intersection with empty set is empty. - let first = true; - let units = new Set(); - const M = new Set(); - // Get for all meters - state.graph.selectedMeters.forEach(meter => { - M.add(meter); - const newUnits = unitsCompatibleWithMeters(M) - if (first) { - // First meter/group so all its units are acceptable at this point - units = newUnits; - first = false; - } else { - // Do intersection of compatible units so far with ones for this meters - units = setIntersect(units, newUnits); - } - }) - // Get for all groups - state.graph.selectedGroups.forEach(async group => { - const newUnits = unitsCompatibleWithMeters(await metersInGroup(group)); - if (first) { - // First meter/group so all its units are acceptable at this point - units = newUnits; - first = false; - } else { - // Do intersection of compatible units so far with ones for this meters - units = setIntersect(newUnits, units); - } - }) + // Retrieve set of units compatible with list of selected meters and/or groups + const units = unitsCompatibleWithMeters(allSelectedMeters); + // Loop over all units (they must be of type unit or suffix - case 1) getVisibleUnitOrSuffixState(state).forEach(o => { // Control displayable ones (case 2) @@ -332,50 +312,237 @@ export function getUnitCompatibilityForDropdown(state: State) { // Should show as incompatible (case 4) incompatibleUnits.add(o.id); } - }) + }); } // Ready to display unit. Put selectable ones before unselectable ones. - const finalUnits = getUnitCompatibility(compatibleUnits, incompatibleUnits, state); + const finalUnits = getSelectOptionsByItem(compatibleUnits, incompatibleUnits, state.units); return finalUnits; } +// NOTE: getMeterCompatibilityForDropdown and getGroupCompatibilityForDropdown are essentially the same function. +// Keeping them separate for now for readability, perhaps they can be consolidated in the future + +/** + * Determines the compatibility of meters in the redux state for display in dropdown + * @param {State} state - current redux state + * @return {SelectOption[]} an array of SelectOption + */ +export function getMeterCompatibilityForDropdown(state: State) { + // Holds all meters visible to the user + const visibleMeters = new Set(); + + // Get all the meters that this user can see. + if (state.currentUser.profile?.role === 'admin') { + // Can see all meters + Object.values(state.meters.byMeterID).forEach(meter => { + visibleMeters.add(meter.id); + }); + } + else { + // Regular user or not logged in so only add displayable meters + Object.values(state.meters.byMeterID).forEach(meter => { + if (meter.displayable) { + visibleMeters.add(meter.id); + } + }); + } + + // meters that can graph + const compatibleMeters = new Set(); + // meters that cannot graph. + const incompatibleMeters = new Set(); + + if (state.graph.selectedUnit === -99) { + // No unit is selected then no meter/group should be selected. + // In this case, every meter is valid (provided it has a default graphic unit) + // If the meter has a default graphic unit set then it can graph, otherwise it cannot. + visibleMeters.forEach(meterId => { + if (state.meters.byMeterID[meterId].defaultGraphicUnit === -99) { + //Default graphic unit is not set + incompatibleMeters.add(meterId); + } + else { + //Default graphic unit is set + compatibleMeters.add(meterId); + } + }); + } + else { + // A unit is selected + // For each meter get all of its compatible units + // Then, check if the selected unit exists in that set of compatible units + visibleMeters.forEach(meterId => { + // Get the set of units compatible with the current meter + const compatibleUnits = unitsCompatibleWithMeters(new Set([meterId])); + if (compatibleUnits.has(state.graph.selectedUnit)) { + // The selected unit is part of the set of compatible units with this meter + compatibleMeters.add(meterId); + } + else { + // The selected unit is not part of the compatible units set for this meter + incompatibleMeters.add(meterId); + } + }); + } + + // Retrieve select options from meter sets + const finalMeters = getSelectOptionsByItem(compatibleMeters, incompatibleMeters, state.meters); + return finalMeters; +} + +/** + * Determines the compatibility of group in the redux state for display in dropdown + * @param {State} state - current redux state + * @return {SelectOption[]} an array of SelectOption + */ +export function getGroupCompatibilityForDropdown(state: State) { + // Holds all groups visible to the user + const visibleGroup = new Set(); + + // Get all the groups that this user can see. + if (state.currentUser.profile?.role === 'admin') { + // Can see all groups + Object.values(state.groups.byGroupID).forEach(group => { + visibleGroup.add(group.id); + }); + } + else { + // Regular user or not logged in so only add displayable groups + Object.values(state.groups.byGroupID).forEach(group => { + if (group.displayable) { + visibleGroup.add(group.id); + } + }); + } + + // groups that can graph + const compatibleGroups = new Set(); + // groups that cannot graph. + const incompatibleGroups = new Set(); + + if (state.graph.selectedUnit === -99) { + // If no unit is selected then no meter/group should be selected. + // In this case, every group is valid (provided it has a default graphic unit) + // If the group has a default graphic unit set then it can graph, otherwise it cannot. + visibleGroup.forEach(groupId => { + if (state.groups.byGroupID[groupId].defaultGraphicUnit === -99) { + //Default graphic unit is not set + incompatibleGroups.add(groupId); + } + else { + //Default graphic unit is set + compatibleGroups.add(groupId); + } + }); + } + else { + // A unit is selected + // For each group get all of its compatible units + // Then, check if the selected unit exists in that set of compatible units + visibleGroup.forEach(groupId => { + // Get the set of units compatible with the current group (through its deepMeters attribute) + const compatibleUnits = unitsCompatibleWithMeters(metersInGroup(groupId)); + if (compatibleUnits.has(state.graph.selectedUnit)) { + // The selected unit is part of the set of compatible units with this group + compatibleGroups.add(groupId); + } + else { + // The selected unit is not part of the compatible units set for this group + incompatibleGroups.add(groupId); + } + }); + } + + // Retrieve select options from group sets + const finalGroups = getSelectOptionsByItem(compatibleGroups, incompatibleGroups, state.groups); + return finalGroups; +} + /** - * Filters all units that are of type meter or displayable type none from the redux state. + * Filters all units that are of type meter or displayable type none from the redux state, as well as admin only units if the user is not an admin. * @param {State} state - current redux state * @return {UnitData[]} an array of UnitData */ export function getVisibleUnitOrSuffixState(state: State) { - const visibleUnitsOrSuffixes = _.filter(state.units.units, function (o: UnitData) { - return o.typeOfUnit != UnitType.meter && o.displayable != DisplayableType.none; - }) + let visibleUnitsOrSuffixes; + if (state.currentUser.profile?.role === 'admin') { + // User is an admin, allow all units to be seen + visibleUnitsOrSuffixes = _.filter(state.units.units, (o: UnitData) => { + return o.typeOfUnit != UnitType.meter && o.displayable != DisplayableType.none; + }); + } + else { + // User is not an admin, do not allow for admin units to be seen + visibleUnitsOrSuffixes = _.filter(state.units.units, (o: UnitData) => { + return o.typeOfUnit != UnitType.meter && o.displayable == DisplayableType.all; + }); + } return visibleUnitsOrSuffixes; } /** - * Sets visibility of SelectOptions for dropdown. Determined by which set they are contained in - * @param {State} state - current redux state - * @param {Set} compatibleUnits - units that are compatible with current selected unit - * @param {Set} incompatibleUnits - units that are not compatible with current selected unit + * Returns a set of SelectOptions based on the type of state passed in and sets the visibility. + * Visibility is determined by which set the items are contained in. + * @param {State} state - current redux state, must be one of UnitsState, MetersState, or GroupsState + * @param {Set} compatibleItems - items that are compatible with current selected options + * @param {Set} incompatibleItems - units that are not compatible with current selected options * @return {SelectOption[]} an array of SelectOption */ -function getUnitCompatibility(compatibleUnits: Set, incompatibleUnits: Set, state: State) { - const finalUnits: SelectOption[] = []; - compatibleUnits.forEach(unit => { - finalUnits.push({ - value: unit, - label: state.units.units[unit].identifier, +function getSelectOptionsByItem(compatibleItems: Set, incompatibleItems: Set, state: UnitsState | MetersState | GroupsState) { + // Holds the label of the select item, set dynamically according to the type of item passed in + let label = ''; + + //The final list of select options to be displayed + const finalItems: SelectOption[] = []; + + //Loop over each itemId and create an activated select option + compatibleItems.forEach(itemId => { + // Perhaps in the future this can be done differently + // Loop over the state type to see what state was passed in (units, meter, group, etc) + // Set the label correctly based on the type of state + // If this is converted to a switch statement the instanceOf function needs to be called twice + // Once for the initial state type check, again because the interpreter (for some reason) needs to be sure that the property exists in the object + // If else statements do not suffer from this + if (instanceOfUnitsState(state)) { + label = state.units[itemId].identifier; + } + else if (instanceOfMetersState(state)) { + label = state.byMeterID[itemId].name; + } + else if (instanceOfGroupsState(state)) { + label = state.byGroupID[itemId].name; + } + else { label = ''; } + finalItems.push({ + value: itemId, + label: label, isDisabled: false } as SelectOption - ) - }) - incompatibleUnits.forEach(unit => { - finalUnits.push({ - value: unit, - label: state.units.units[unit].identifier, + ); + }); + //Loop over each itemId and create a disabled select option + incompatibleItems.forEach(itemId => { + if (instanceOfUnitsState(state)) { + label = state.units[itemId].identifier; + } + else if (instanceOfMetersState(state)) { + label = state.byMeterID[itemId].name; + } + else if (instanceOfGroupsState(state)) { + label = state.byGroupID[itemId].name; + } + else { label = ''; } + finalItems.push({ + value: itemId, + label: label, isDisabled: true } as SelectOption - ) + ); }) - return _.sortBy(_.sortBy(finalUnits, unit => unit.label.toLowerCase(), 'asc'), unit => unit.isDisabled, 'asc'); + return _.sortBy(_.sortBy(finalItems, item => item.label.toLowerCase(), 'asc'), item => item.isDisabled, 'asc'); } -// } \ No newline at end of file + +// Helper functions to determine what type of state was passed in +function instanceOfUnitsState(state: any): state is UnitsState { return 'units' in state; } +function instanceOfMetersState(state: any): state is MetersState { return 'byMeterID' in state; } +function instanceOfGroupsState(state: any): state is GroupsState { return 'byGroupID' in state; } diff --git a/src/client/app/components/GraphicRateMenuComponent.tsx b/src/client/app/components/GraphicRateMenuComponent.tsx new file mode 100644 index 000000000..81f37631a --- /dev/null +++ b/src/client/app/components/GraphicRateMenuComponent.tsx @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { State } from '../types/redux/state'; +import { useDispatch, useSelector } from 'react-redux'; +import { SelectOption } from '../types/items'; +import Select from 'react-select'; +import translate from '../utils/translate'; +import { updateLineGraphRate } from '../actions/graph' +import { LineGraphRate, LineGraphRates } from '../types/redux/graph'; + +export default function GraphicRateMenuComponent() { + const dispatch = useDispatch(); + + // Graph state + const graphState = useSelector((state: State) => state.graph); + + // Array of select options created from the rates + const rateOptions: SelectOption[] = []; + + //Loop over our rates object to create the selects for the dropdown + Object.entries(LineGraphRates).forEach(([rateKey, rateValue]) => { + rateOptions.push({ + label: translate(rateKey), + value: rateValue, + labelIdForTranslate: rateKey + } as SelectOption); + }); + + const labelStyle: React.CSSProperties = { + fontWeight: 'bold', + margin: 0 + }; + + return ( +
+ { + graphState.chartToRender == 'line' && +
+

:

+ { /* On change update the line graph rate in the store after a null check */} + handleIdentifierChange(e)} + defaultValue={identifier} + required value={identifier} + placeholder="Identifier" /> +
+ {/* Name input*/} +
+
+ handleNameChange(e)} + defaultValue={name} + required value={name} + placeholder="Name" /> +
+ {/* Area input*/} +
+
+ handleAreaChange(e)} /> +
+ {/* Enabled input*/} +
+
+ handleEnabledChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* Displayable input*/} +
+
+ handleDisplayableChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* Meter type input*/} +
+
+ handleMeterTypeChange(e)}> + {Object.keys(MeterType).map(key => { + return () + })} + +
+ {/* URL input*/} +
+
+ handleUrlChange(e)} + defaultValue={url} + placeholder="URL" /> +
+ {/* Timezone input*/} +
+
+ handleTimeZoneChange(e)} + defaultValue={timeZone} + placeholder="Time Zone" /> +
+ {/* GPS input*/} +
+
+ handleGpsChange(e)} + defaultValue={gps} + placeholder="latitude, longitude" /> +
+ {/* UnitId input*/} +
+
+ handleUnitIDChange(e)} + defaultValue={unitId} + placeholder="unitId" /> +
+ {/* DefaultGraphicUnit input*/} +
+
+ handleDefaultGraphicUnitChange(e)} + defaultValue={defaultGraphicUnit} + placeholder="defaultGraphicUnit" /> +
+ {/* note input*/} +
+
+ handleNoteChange(e)} + defaultValue={note} + placeholder='Note' /> +
+ {/* cumulative input*/} +
+
+ handleCumulativeChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* cumulativeReset input*/} +
+
+ handleCumulativeResetChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* cumulativeResetStart input*/} +
+
+ handleCumulativeResetStartChange(e)} + defaultValue={cumulativeResetStart} + placeholder="HH:MM:SS" /> +
+ {/* cumulativeResetEnd input*/} +
+
+ handleCumulativeResetEndChange(e)} + defaultValue={cumulativeResetEnd} + placeholder="HH:MM:SS" /> +
+ {/* endOnlyTime input*/} +
+
+ handleEndOnlyTimeChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* reading input*/} +
+
+ handleReadingChange(e)} + step="0.01" + defaultValue={reading} /> +
+ {/* readingGap input*/} +
+
+ handleReadingGapChange(e)} + step="0.01" + min="0" + defaultValue={readingGap} /> +
+ {/* readingVariation input*/} +
+
+ handleReadingVariationChange(e)} + step="0.01" + min="0" + defaultValue={readingVariation} /> +
+ {/* readingDuplication input*/} +
+
+ handleReadingDuplicationChange(e)} + step="1" + min="1" + max="9" + defaultValue={readingDuplication} /> +
+ {/* timeSort input*/} +
+
+ handleTimeSortChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* startTimestamp input*/} +
+
+ handleStartTimestampChange(e)} + placeholder="YYYY-MM-DD HH:MM:SS" + defaultValue={startTimestamp} /> +
+ {/* endTimestamp input*/} +
+
+ handleEndTimestampChange(e)} + placeholder="YYYY-MM-DD HH:MM:SS" + defaultValue={endTimestamp} /> +
+
+
+
+ + + + + {/* Hides the modal */} + + {/* On click calls the function handleSaveChanges in this component */} + + + + + ); +} \ No newline at end of file diff --git a/src/client/app/components/meters/EditMeterModalComponent.tsx b/src/client/app/components/meters/EditMeterModalComponent.tsx new file mode 100644 index 000000000..86dce538b --- /dev/null +++ b/src/client/app/components/meters/EditMeterModalComponent.tsx @@ -0,0 +1,580 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; +import { Modal, Button } from 'react-bootstrap'; +import { MeterData, MeterType } from '../../types/redux/meters'; +import { Input } from 'reactstrap'; +import { FormattedMessage } from 'react-intl'; +import translate from '../../utils/translate'; +import { useDispatch } from 'react-redux'; +import { submitEditedMeter } from '../../actions/meters'; +import { removeUnsavedChanges } from '../../actions/unsavedWarning'; +import { useState } from 'react'; +import '../../styles/Modal.unit.css'; +import { TrueFalseType } from '../../types/items'; + +interface EditMeterModalComponentProps { + show: boolean; + meter: MeterData; + // passed in to handle closing the modal + handleClose: () => void; +} + +// Updated to hooks +export default function EditMeterModalComponent(props: EditMeterModalComponentProps) { + + const dispatch = useDispatch(); + + /* State */ + + // identifier + const [identifier, setIdentifier] = useState(props.meter.identifier); + const handleIdentifierChange = (e: React.ChangeEvent) => { + setIdentifier(e.target.value); + } + + // name + const [name, setName] = useState(props.meter.name); + const handleNameChange = (e: React.ChangeEvent) => { + setName(e.target.value); + } + + // area + const [area, setArea] = useState(props.meter.area); + const handleAreaChange = (e: React.ChangeEvent) => { + setArea(Number(e.target.value)); + } + + // enabled + const [enabled, setEnabled] = useState(props.meter.enabled); + const handleEnabledChange = (e: React.ChangeEvent) => { + setEnabled(JSON.parse(e.target.value)); + } + + // displayable + const [displayable, setDisplayable] = useState(props.meter.displayable); + const handleDisplayableChange = (e: React.ChangeEvent) => { + setDisplayable(JSON.parse(e.target.value)); + } + + // meterType + const [meterType, setMeterType] = useState(props.meter.meterType? `${props.meter.meterType}` : ''); + const handleMeterTypeChange = (e: React.ChangeEvent) => { + setMeterType(e.target.value); + } + + // URL + const [url, setUrl] = useState(props.meter.url); + const handleUrlChange = (e: React.ChangeEvent) => { + setUrl(e.target.value); + } + + // timeZone + const [timeZone, setTimeZone] = useState(props.meter.timeZone? `${props.meter.timeZone.abbrev}, + ${props.meter.timeZone.name}, ${props.meter.timeZone.offset}` : ''); + const handleTimeZoneChange = (e: React.ChangeEvent) => { + setTimeZone(e.target.value); + } + + // GPS + const [gps, setGps] = useState(props.meter.gps? `${props.meter.gps.latitude}, ${props.meter.gps.latitude}` : + '' + props.meter.gps + ' , ' + props.meter.gps); + const handleGpsChange = (e: React.ChangeEvent) => { + // check input + // format input + setGps(e.target.value); + } + + // unitID + const [unitId, setUnitID] = useState(props.meter.unitId); + const handleUnitIDChange = (e: React.ChangeEvent) => { + setUnitID(Number(e.target.value)); + } + + // defaultGraphicUnit + const [defaultGraphicUnit, setDefaultGraphicUnit] = useState(props.meter.defaultGraphicUnit); + const handleDefaultGraphicUnitChange = (e: React.ChangeEvent) => { + setDefaultGraphicUnit(Number(e.target.value)); + } + + // note + const [note, setNote] = useState(props.meter.note); + const handleNoteChange = (e: React.ChangeEvent) => { + setNote(e.target.value); + } + + // cumulative + const [cumulative, setCumulative] = useState(props.meter.cumulative); + const handleCumulativeChange = (e: React.ChangeEvent) => { + setCumulative(JSON.parse(e.target.value)); + } + + // cumulativeReset + const [cumulativeReset, setCumulativeReset] = useState(props.meter.cumulativeReset); + const handleCumulativeResetChange = (e: React.ChangeEvent) => { + setCumulativeReset(JSON.parse(e.target.value)); + } + + // cumulativeResetStart + const [cumulativeResetStart, setCumulativeResetStart] = useState(props.meter.cumulativeResetStart); + const handleCumulativeResetStartChange = (e: React.ChangeEvent) => { + setCumulativeResetStart(e.target.value); + } + + // cumulativeResetEnd + const [cumulativeResetEnd, setCumulativeResetEnd] = useState(props.meter.cumulativeResetEnd); + const handleCumulativeResetEndChange = (e: React.ChangeEvent) => { + setCumulativeResetEnd(e.target.value); + } + + // endOnlyTime + const [endOnlyTime, setEndOnlyTime] = useState(props.meter.endOnlyTime); + const handleEndOnlyTimeChange = (e: React.ChangeEvent) => { + setEndOnlyTime(JSON.parse(e.target.value)); + } + + // reading + const [reading, setReading] = useState(props.meter.reading); + const handleReadingChange = (e: React.ChangeEvent) => { + setReading(Number(e.target.value)); + } + + // readingGap + const [readingGap, setReadingGap] = useState(props.meter.readingGap); + const handleReadingGapChange = (e: React.ChangeEvent) => { + setReadingGap(Number(e.target.value)); + } + + // readingVariation + const [readingVariation, setReadingVariation] = useState(props.meter.readingVariation); + const handleReadingVariationChange = (e: React.ChangeEvent) => { + setReadingVariation(Number(e.target.value)); + } + + // readingDuplication + const [readingDuplication, setReadingDuplication] = useState(props.meter.readingDuplication); + const handleReadingDuplicationChange = (e: React.ChangeEvent) => { + setReadingDuplication(Number(e.target.value)); + } + + // timeSort + const [timeSort, setTimeSort] = useState(props.meter.timeSort); + const handleTimeSortChange = (e: React.ChangeEvent) => { + setTimeSort(JSON.parse(e.target.value)); + } + + // startTimestamp + const [startTimestamp, setStartTimestamp] = useState(props.meter.startTimestamp); + const handleStartTimestampChange = (e: React.ChangeEvent) => { + setStartTimestamp(e.target.value); + } + + // endTimestamp + const [endTimestamp, setEndTimestamp] = useState(props.meter.endTimestamp); + const handleEndTimestampChange = (e: React.ChangeEvent) => { + setEndTimestamp(e.target.value); + } + + + /* End State */ + + // Reset the state to default values + // To be used for the discard changes button + // Different use case from CreateMeterModalComponent's resetState + // This allows us to reset our state to match the store in the event of an edit failure + // Failure to edit meters will not trigger a re-render, as no state has changed. Therefore, we must manually reset the values + const resetState = () => { + setIdentifier(props.meter.identifier); + setName(props.meter.name); + setEnabled(props.meter.enabled); + setDisplayable(props.meter.displayable); + setMeterType(props.meter.meterType? `${props.meter.meterType}` : ''); + setUrl(props.meter.url); + setTimeZone(props.meter.timeZone? `${props.meter.timeZone.abbrev}, ${props.meter.timeZone.name}, ${props.meter.timeZone.offset}` : ''); + setGps(props.meter.gps? `${props.meter.gps.latitude}, ${props.meter.gps.longitude}` : ''); + setUnitID(props.meter.unitId); + setDefaultGraphicUnit(props.meter.defaultGraphicUnit); + setNote(props.meter.note); + setCumulative(props.meter.cumulative); + setCumulativeReset(props.meter.cumulativeReset); + setCumulativeResetStart(props.meter.cumulativeResetStart); + setCumulativeResetEnd(props.meter.cumulativeResetEnd); + setEndOnlyTime(props.meter.endOnlyTime); + setReading(props.meter.reading); + setReadingGap(props.meter.readingGap); + setReadingVariation(props.meter.readingVariation); + setReadingDuplication(props.meter.readingDuplication); + setTimeSort(props.meter.timeSort); + setStartTimestamp(props.meter.startTimestamp); + setEndTimestamp(props.meter.endTimestamp); + } + + const handleClose = () => { + props.handleClose(); + resetState(); + } + + // Save changes + // Currently using the old functionality which is to compare inherited prop values to state values + // If there is a difference between props and state, then a change was made + // Side note, we could probably just set a boolean when any input i + const handleSaveChanges = () => { + + // Close the modal first to avoid repeat clicks + props.handleClose(); + + // Check for changes by comparing state to props + const meterHasChanges = + ( + props.meter.identifier != identifier || + props.meter.name != name || + props.meter.area != area || + props.meter.enabled != enabled || + props.meter.displayable != displayable || + props.meter.meterType != meterType || + props.meter.url != url || + props.meter.timeZone != timeZone || + props.meter.gps != gps || + props.meter.unitId != unitId || + props.meter.defaultGraphicUnit != defaultGraphicUnit || + props.meter.note != note || + props.meter.cumulative != cumulative || + props.meter.cumulativeReset != cumulativeReset || + props.meter.cumulativeResetStart != cumulativeResetStart || + props.meter.cumulativeResetEnd != cumulativeResetEnd || + props.meter.endOnlyTime != endOnlyTime || + props.meter.reading != reading || + props.meter.readingGap != readingGap || + props.meter.readingVariation != readingVariation || + props.meter.readingDuplication != readingDuplication || + props.meter.timeSort != timeSort || + props.meter.startTimestamp != startTimestamp || + props.meter.endTimestamp != endTimestamp ); + + // Only do work if there are changes + if (meterHasChanges) { + const editedMeter = { + ...props.meter, + identifier, + name, + area, + enabled, + displayable, + meterType, + url, + timeZone, + gps, + unitId, + defaultGraphicUnit, + note, + cumulative, + cumulativeReset, + cumulativeResetStart, + cumulativeResetEnd, + endOnlyTime, + reading, + readingGap, + readingVariation, + readingDuplication, + timeSort, + startTimestamp, + endTimestamp + } + + // Save our changes by dispatching the submitEditedMeter action + dispatch(submitEditedMeter(editedMeter)); + // The updated meter is not fetched to save time. However, the identifier might have been + // automatically set if it was empty. Mimic that here. + if (editedMeter.identifier === '') { + editedMeter.identifier = editedMeter.name; + } + dispatch(removeUnsavedChanges()); + } + + } + + const formInputStyle: React.CSSProperties = { + paddingBottom: '5px' + } + + const tableStyle: React.CSSProperties = { + width: '100%' + }; + + return ( + <> + + + + + + {/* when any of the meter are changed call one of the functions. */} + +
+
+ {/* Modal content */} +
+
+ {/* Identifier input*/} +
+
+ handleIdentifierChange(e)} + defaultValue={identifier} + required value={identifier} + placeholder="Identifier" /> +
+ {/* Name input*/} +
+
+ handleNameChange(e)} + defaultValue={name} + required value={name} + placeholder="Name" /> +
+ {/* Area input*/} +
+
+ handleAreaChange(e)} /> +
+ {/* Enabled input*/} +
+
+ handleEnabledChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* Displayable input*/} +
+
+ handleDisplayableChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* Meter type input*/} +
+
+ handleMeterTypeChange(e)}> + {Object.keys(MeterType).map(key => { + return () + })} + +
+ {/* URL input*/} +
+
+ handleUrlChange(e)} + defaultValue={url} + placeholder="URL" /> +
+ {/* Timezone input*/} +
+
+ handleTimeZoneChange(e)} + defaultValue={timeZone} + placeholder="Time Zone" /> +
+ {/* GPS input*/} +
+
+ handleGpsChange(e)} + defaultValue={gps} + placeholder="latitude, longitude" /> +
+ {/* UnitId input*/} +
+
+ handleUnitIDChange(e)} + defaultValue={unitId} + placeholder="unitId" /> +
+ {/* DefaultGraphicUnit input*/} +
+
+ handleDefaultGraphicUnitChange(e)} + defaultValue={defaultGraphicUnit} + placeholder="defaultGraphicUnit" /> +
+ {/* note input*/} +
+
+ handleNoteChange(e)} + defaultValue={note} + placeholder='Note' /> +
+ {/* cumulative input*/} +
+
+ handleCumulativeChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* cumulativeReset input*/} +
+
+ handleCumulativeResetChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* cumulativeResetStart input*/} +
+
+ handleCumulativeResetStartChange(e)} + defaultValue={cumulativeResetStart} + placeholder="HH:MM:SS" /> +
+ {/* cumulativeResetEnd input*/} +
+
+ handleCumulativeResetEndChange(e)} + defaultValue={cumulativeResetEnd} + placeholder="HH:MM:SS" /> +
+ {/* endOnlyTime input*/} +
+
+ handleEndOnlyTimeChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* reading input*/} +
+
+ handleReadingChange(e)} + step="0.01" + defaultValue={reading} /> +
+ {/* readingGap input*/} +
+
+ handleReadingGapChange(e)} + step="0.01" + min="0" + defaultValue={readingGap} /> +
+ {/* readingVariation input*/} +
+
+ handleReadingVariationChange(e)} + step="0.01" + min="0" + defaultValue={readingVariation} /> +
+ {/* readingDuplication input*/} +
+
+ handleReadingDuplicationChange(e)} + step="1" + min="1" + max="9" + defaultValue={readingDuplication} /> +
+ {/* timeSort input*/} +
+
+ handleTimeSortChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return () + })} + +
+ {/* startTimestamp input*/} +
+
+ handleStartTimestampChange(e)} + placeholder="YYYY-MM-DD HH:MM:SS" + defaultValue={startTimestamp} /> +
+ {/* endTimestamp input*/} +
+
+ handleEndTimestampChange(e)} + placeholder="YYYY-MM-DD HH:MM:SS" + defaultValue={endTimestamp} /> +
+
+
+
+
+
+ + + {/* Hides the modal */} + + {/* On click calls the function handleSaveChanges in this component */} + + + + + ); +} \ No newline at end of file diff --git a/src/client/app/components/meters/MeterViewComponent.tsx b/src/client/app/components/meters/MeterViewComponent.tsx index 9a0c93b7a..623882858 100644 --- a/src/client/app/components/meters/MeterViewComponent.tsx +++ b/src/client/app/components/meters/MeterViewComponent.tsx @@ -1,344 +1,51 @@ /* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; import { Button } from 'reactstrap'; -import { FormattedMessage, injectIntl, WrappedComponentProps } from 'react-intl'; -import { MeterMetadata, EditMeterDetailsAction } from '../../types/redux/meters'; -import { GPSPoint, isValidGPSInput } from '../../utils/calibration'; -import TimeZoneSelect from '../TimeZoneSelect'; -import { updateUnsavedChanges } from '../../actions/unsavedWarning'; -import { fetchMetersDetails, submitEditedMeters, confirmEditedMeters } from '../../actions/meters'; -import store from '../../index'; - -interface MeterViewProps { - // The ID of the meter to be displayed - id: number; - // The meter metadata being displayed by this row - meter: MeterMetadata; - isEdited: boolean; - isSubmitting: boolean; - loggedInAsAdmin: boolean; - // The function used to dispatch the action to edit meter details - editMeterDetails(meter: MeterMetadata): EditMeterDetailsAction; - log(level: string, message: string): any; -} - -interface MeterViewState { - gpsFocus: boolean; - gpsInput: string; - identifierFocus: boolean; - identifierInput: string; -} - -type MeterViewPropsWithIntl = MeterViewProps & WrappedComponentProps; - -class MeterViewComponent extends React.Component { - constructor(props: MeterViewPropsWithIntl) { - super(props); - this.state = { - gpsFocus: false, - gpsInput: (this.props.meter.gps) ? `${this.props.meter.gps.latitude},${this.props.meter.gps.longitude}` : '', - identifierFocus: false, - identifierInput: this.props.meter.identifier - }; - this.toggleMeterDisplayable = this.toggleMeterDisplayable.bind(this); - this.toggleMeterEnabled = this.toggleMeterEnabled.bind(this); - this.toggleGPSInput = this.toggleGPSInput.bind(this); - this.handleGPSChange = this.handleGPSChange.bind(this); - this.changeTimeZone = this.changeTimeZone.bind(this); - this.toggleIdentifierInput = this.toggleIdentifierInput.bind(this); - this.handleIdentifierChange = this.handleIdentifierChange.bind(this); - } - - public render() { - const loggedInAsAdmin = this.props.loggedInAsAdmin; - return ( - - {loggedInAsAdmin && {this.props.meter.id} {this.formatStatus()} } - {loggedInAsAdmin && {this.props.meter.name} } - {this.formatIdentifierInput()} - {loggedInAsAdmin && {this.props.meter.meterType} } - {loggedInAsAdmin && {this.props.meter.url} } - {loggedInAsAdmin && {this.formatGPSInput()} } - {this.formatEnabled()} - {loggedInAsAdmin && {this.formatDisplayable()} } - {loggedInAsAdmin && } - - ); - } - - private removeUnsavedChangesFunction(callback: () => void) { - // This function is called to reset all the inputs to the initial state - store.dispatch(confirmEditedMeters()).then(() => { - store.dispatch(fetchMetersDetails()).then(callback); - }); - } - - private submitUnsavedChangesFunction(successCallback: () => void, failureCallback: () => void) { - // This function is called to submit the unsaved changes - store.dispatch(submitEditedMeters()).then(successCallback, failureCallback); - } - - private updateUnsavedChanges() { - // Notify that there are unsaved changes - store.dispatch(updateUnsavedChanges(this.removeUnsavedChangesFunction, this.submitUnsavedChangesFunction)); - } - - componentDidUpdate(prevProps: MeterViewProps) { - if (this.props.isEdited && !prevProps.isEdited) { - // When the props.isEdited changes from false to true, there are unsaved changes - this.updateUnsavedChanges(); - } - } - - private formatStatus(): string { - if (this.props.isSubmitting) { - return '(' + this.props.intl.formatMessage({ id: 'submitting' }) + ')'; - } - - if (this.props.isEdited) { - return this.props.intl.formatMessage({ id: 'edited' }); - } - - return ''; - } - - private styleEnabled(): React.CSSProperties { - return { color: 'green' }; - } - - private styleDisabled(): React.CSSProperties { - return { color: 'red' }; - } - - private styleToggleBtn(): React.CSSProperties { - return { float: 'right' }; - } - - private toggleMeterDisplayable() { - const editedMeter = this.props.meter; - editedMeter.displayable = !editedMeter.displayable; - this.props.editMeterDetails(editedMeter); - } - - private toggleMeterEnabled() { - const editedMeter = this.props.meter; - editedMeter.enabled = !editedMeter.enabled; - this.props.editMeterDetails(editedMeter); - } - - private changeTimeZone(value: string): void { - const editedMeter = this.props.meter; - editedMeter.timeZone = value; - this.props.editMeterDetails(editedMeter); - } - - private formatDisplayable() { - let styleFn; - let messageId; - let buttonMessageId; - - if (this.props.meter.displayable) { - styleFn = this.styleEnabled; - messageId = 'meter.is.displayable'; - buttonMessageId = 'hide'; - } else { - styleFn = this.styleDisabled; - messageId = 'meter.is.not.displayable'; - buttonMessageId = 'show'; - } - - let toggleButton; - const loggedInAsAdmin = this.props.loggedInAsAdmin; - if (loggedInAsAdmin) { - toggleButton = ; - } else { - toggleButton =
; - } - - return ( - - - - - {toggleButton} - - ); - } - - private formatEnabled() { - let styleFn; - let messageId; - let buttonMessageId; - - if (this.props.meter.enabled) { - styleFn = this.styleEnabled; - messageId = 'meter.is.enabled'; - buttonMessageId = 'disable'; - } else { - styleFn = this.styleDisabled; - messageId = 'meter.is.not.enabled'; - buttonMessageId = 'enable'; - } - - let toggleButton; - const loggedInAsAdmin = this.props.loggedInAsAdmin; - if (loggedInAsAdmin) { - toggleButton = ; - } else { - toggleButton =
; - } - - return ( - - - - - {toggleButton} - - ); - - } - - private toggleGPSInput() { - if (this.state.gpsFocus) { - const input = this.state.gpsInput; - if (input.length === 0) { - const editedMeter = { - ...this.props.meter, - gps: undefined - }; - this.props.editMeterDetails(editedMeter); - } else if (isValidGPSInput(input)) { - const latitudeIndex = 0; - const longitudeIndex = 1; - const array = input.split(',').map((value: string) => parseFloat(value)); - const gps: GPSPoint = { - longitude: array[longitudeIndex], - latitude: array[latitudeIndex] - }; - const editedMeter = { - ...this.props.meter, - gps - }; - this.props.editMeterDetails(editedMeter); - } else { - this.props.log('info', 'refused gps coordinates with invalid input'); - const originalGPS = this.props.meter.gps; - this.setState({ gpsInput: (originalGPS) ? `${originalGPS.longitude},${originalGPS.latitude}` : '' }); - } - } - this.setState({ gpsFocus: !this.state.gpsFocus }); - } - - private handleGPSChange(event: React.ChangeEvent) { - this.setState({ gpsInput: event.target.value }); - } - - private formatGPSInput() { - let formattedGPS; - let buttonMessageId; - if (this.state.gpsFocus) { - // default value for autoFocus is true and for all attributes that would be set autoFocus={true} - formattedGPS =