diff --git a/package-lock.json b/package-lock.json index dbb5e68b40..5a65f549bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "classnames": "^2.5.1", "date-fns": "^3.6.0", "font-awesome": "4.7.0", + "html-to-text": "^9.0.5", "jquery": "^3.7.1", "js-cookie": "^3.0.5", "lodash": "^4.17.21", @@ -32,7 +33,8 @@ "react-select": "^5.8.0", "redux": "^4.1.1", "redux-logger": "^3.0.6", - "redux-thunk": "^2.3.0" + "redux-thunk": "^2.3.0", + "use-debounce": "^10.0.0" }, "devDependencies": { "@babel/cli": "^7.24.1", @@ -2498,6 +2500,18 @@ "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", @@ -3710,6 +3724,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3782,6 +3804,57 @@ "@babel/runtime": "^7.1.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.5.tgz", @@ -3810,6 +3883,17 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/envinfo": { "version": "7.11.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", @@ -5061,6 +5145,39 @@ "react-is": "^16.7.0" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -5729,6 +5846,14 @@ "node": ">=0.10.0" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6163,6 +6288,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6203,6 +6340,14 @@ "node": ">=8" } }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/picocolors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", @@ -7155,6 +7300,17 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -7772,6 +7928,17 @@ "punycode": "^2.1.0" } }, + "node_modules/use-debounce": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.3.tgz", + "integrity": "sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg==", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", @@ -9897,6 +10064,15 @@ "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" }, + "@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "requires": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + } + }, "@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", @@ -10799,6 +10975,11 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + }, "define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -10853,6 +11034,39 @@ "@babel/runtime": "^7.1.2" } }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, "electron-to-chromium": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.5.tgz", @@ -10875,6 +11089,11 @@ "tapable": "^2.2.0" } }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, "envinfo": { "version": "7.11.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", @@ -11780,6 +11999,29 @@ "react-is": "^16.7.0" } }, + "html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "requires": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + } + }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -12240,6 +12482,11 @@ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, + "leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==" + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12558,6 +12805,15 @@ "lines-and-columns": "^1.1.6" } }, + "parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "requires": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + } + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -12586,6 +12842,11 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, + "peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==" + }, "picocolors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", @@ -13237,6 +13498,14 @@ } } }, + "selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "requires": { + "parseley": "^0.12.0" + } + }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -13669,6 +13938,12 @@ "punycode": "^2.1.0" } }, + "use-debounce": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.3.tgz", + "integrity": "sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg==", + "requires": {} + }, "use-isomorphic-layout-effect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", diff --git a/package.json b/package.json index 066ec6aa30..9c2ab2ca27 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "build:dist": "webpack --config webpack.config.js --mode production --env ignore-perf --fail-on-warnings", "build:prod": "webpack --config webpack.config.js --mode production", "build": "webpack --config webpack.config.js --mode development", - "watch": "webpack --config webpack.config.js --mode development --watch" + "watch": "webpack --config webpack.config.js --mode development --watch", + "lint": "eslint --ext .js rdmo/" }, "author": "RDMO Arbeitsgemeinschaft ", "license": "Apache-2.0", @@ -20,6 +21,7 @@ "classnames": "^2.5.1", "date-fns": "^3.6.0", "font-awesome": "4.7.0", + "html-to-text": "^9.0.5", "jquery": "^3.7.1", "js-cookie": "^3.0.5", "lodash": "^4.17.21", @@ -28,6 +30,7 @@ "react": "^18.3.1", "react-bootstrap": "0.33.1", "react-datepicker": "7.3.0", + "react-diff-viewer-continued": "^3.4.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", @@ -37,7 +40,7 @@ "redux": "^4.1.1", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0", - "react-diff-viewer-continued": "^3.4.0" + "use-debounce": "^10.0.0" }, "devDependencies": { "@babel/cli": "^7.24.1", diff --git a/rdmo/core/assets/js/utils/lang.js b/rdmo/core/assets/js/utils/lang.js new file mode 100644 index 0000000000..24bce4d468 --- /dev/null +++ b/rdmo/core/assets/js/utils/lang.js @@ -0,0 +1,2 @@ +// take the baseurl from the of the django template +export default document.querySelector('html').getAttribute('lang') diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py index 0e6add4dc8..b19fc15358 100644 --- a/rdmo/core/settings.py +++ b/rdmo/core/settings.py @@ -222,7 +222,21 @@ 'PROJECT_TABLE_PAGE_SIZE' ] -TEMPLATES_API = [] +TEMPLATES_API = [ + 'projects/project_interview_add_set_help.html', + 'projects/project_interview_add_value_help.html', + 'projects/project_interview_buttons_help.html', + 'projects/project_interview_done.html', + 'projects/project_interview_error.html', + 'projects/project_interview_multiple_values_warning.html', + 'projects/project_interview_navigation_help.html', + 'projects/project_interview_overview_help.html', + 'projects/project_interview_page_help.html', + 'projects/project_interview_page_tabs_help.html', + 'projects/project_interview_progress_help.html', + 'projects/project_interview_question_help.html', + 'projects/project_interview_questionset_help.html', +] EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'info@example.com' diff --git a/rdmo/core/templates/core/base.html b/rdmo/core/templates/core/base.html index d9508e1be9..4f3bb00e33 100644 --- a/rdmo/core/templates/core/base.html +++ b/rdmo/core/templates/core/base.html @@ -1,5 +1,5 @@ -{% load static compress core_tags %} - +{% load static compress core_tags i18n %}{% get_current_language as lang_code %} + {% include 'core/base_head.html' %} diff --git a/rdmo/management/assets/js/containers/Pending.js b/rdmo/management/assets/js/containers/Pending.js deleted file mode 100644 index 7b421c49df..0000000000 --- a/rdmo/management/assets/js/containers/Pending.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { connect } from 'react-redux' - -const Pending = ({ config }) => { - if (config.pending) { - return - } else { - return null - } -} - -Pending.propTypes = { - config: PropTypes.object.isRequired, -} - -function mapStateToProps(state) { - return { - config: state.config, - } -} - -export default connect(mapStateToProps)(Pending) diff --git a/rdmo/management/assets/js/management.js b/rdmo/management/assets/js/management.js index 55b37879ed..122f043cd2 100644 --- a/rdmo/management/assets/js/management.js +++ b/rdmo/management/assets/js/management.js @@ -7,9 +7,10 @@ import configureStore from './store/configureStore' import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' +import Pending from '../../../core/assets/js/containers/Pending' + import Main from './containers/Main' import Sidebar from './containers/Sidebar' -import Pending from './containers/Pending' const store = configureStore() diff --git a/rdmo/options/providers.py b/rdmo/options/providers.py index cc1c6bebe3..0990632623 100644 --- a/rdmo/options/providers.py +++ b/rdmo/options/providers.py @@ -15,6 +15,8 @@ def get_options(self, project, search=None, user=None, site=None): class SimpleProvider(Provider): + refresh = True + def get_options(self, project, search=None, user=None, site=None): return [ { diff --git a/rdmo/projects/admin.py b/rdmo/projects/admin.py index 926885e1c5..bf8956f28d 100644 --- a/rdmo/projects/admin.py +++ b/rdmo/projects/admin.py @@ -129,7 +129,7 @@ def project_owners(self, obj): @admin.register(Value) class ValueAdmin(admin.ModelAdmin): search_fields = ('attribute__uri', 'project__title', 'snapshot__title', 'project__user__username') - list_display = ('attribute', 'set_prefix', 'set_index', 'collection_index', 'value_type', + list_display = ('attribute', 'set_prefix', 'set_index', 'collection_index', 'set_collection', 'value_type', 'project_title', 'project_owners', 'snapshot_title', 'updated', 'created') list_filter = ('value_type', ) diff --git a/rdmo/projects/assets/js/interview.js b/rdmo/projects/assets/js/interview.js new file mode 100644 index 0000000000..97435a43f5 --- /dev/null +++ b/rdmo/projects/assets/js/interview.js @@ -0,0 +1,35 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { Provider } from 'react-redux' + +import configureStore from './interview/store/configureStore' + +import { DndProvider } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' + +import Pending from '../../../core/assets/js/containers/Pending' + +import Main from './interview/containers/Main' +import Sidebar from './interview/containers/Sidebar' + +const store = configureStore() + +createRoot(document.getElementById('main')).render( + + +
+ + +) + +createRoot(document.getElementById('sidebar')).render( + + + +) + +createRoot(document.getElementById('pending')).render( + + + +) diff --git a/rdmo/projects/assets/js/interview/actions/actionTypes.js b/rdmo/projects/assets/js/interview/actions/actionTypes.js new file mode 100644 index 0000000000..d8025bf3ad --- /dev/null +++ b/rdmo/projects/assets/js/interview/actions/actionTypes.js @@ -0,0 +1,52 @@ +export const NOOP = 'NOOP' + +export const FETCH_OVERVIEW_INIT = 'FETCH_OVERVIEW_INIT' +export const FETCH_OVERVIEW_ERROR = 'FETCH_OVERVIEW_ERROR' +export const FETCH_OVERVIEW_SUCCESS = 'FETCH_OVERVIEW_SUCCESS' + +export const FETCH_PROGRESS_INIT = 'FETCH_PROGRESS_INIT' +export const FETCH_PROGRESS_ERROR = 'FETCH_PROGRESS_ERROR' +export const FETCH_PROGRESS_SUCCESS = 'FETCH_PROGRESS_SUCCESS' + +export const UPDATE_PROGRESS_INIT = 'UPDATE_PROGRESS_INIT' +export const UPDATE_PROGRESS_SUCCESS = 'UPDATE_PROGRESS_SUCCESS' +export const UPDATE_PROGRESS_ERROR = 'UPDATE_PROGRESS_ERROR' + +export const FETCH_PAGE_INIT = 'FETCH_PAGE_INIT' +export const FETCH_PAGE_ERROR = 'FETCH_PAGE_ERROR' +export const FETCH_PAGE_SUCCESS = 'FETCH_PAGE_SUCCESS' + +export const FETCH_NAVIGATION_INIT = 'FETCH_NAVIGATION_INIT' +export const FETCH_NAVIGATION_ERROR = 'FETCH_NAVIGATION_ERROR' +export const FETCH_NAVIGATION_SUCCESS = 'FETCH_NAVIGATION_SUCCESS' + +export const FETCH_OPTIONS_INIT = 'FETCH_OPTIONS_INIT' +export const FETCH_OPTIONS_SUCCESS = 'FETCH_OPTIONS_SUCCESS' +export const FETCH_OPTIONS_ERROR = 'FETCH_OPTIONS_ERROR' + +export const FETCH_VALUES_INIT = 'FETCH_VALUES_INIT' +export const FETCH_VALUES_SUCCESS = 'FETCH_VALUES_SUCCESS' +export const FETCH_VALUES_ERROR = 'FETCH_VALUES_ERROR' + +export const RESOLVE_CONDITION_INIT = 'RESOLVE_CONDITION_INIT' +export const RESOLVE_CONDITION_SUCCESS = 'RESOLVE_CONDITION_SUCCESS' +export const RESOLVE_CONDITION_ERROR = 'RESOLVE_CONDITION_ERROR' + +export const CREATE_VALUE = 'CREATE_VALUE' +export const UPDATE_VALUE = 'UPDATE_VALUE' + +export const STORE_VALUE_INIT = 'STORE_VALUE_INIT' +export const STORE_VALUE_SUCCESS = 'STORE_VALUE_SUCCESS' +export const STORE_VALUE_ERROR = 'STORE_VALUE_ERROR' + +export const DELETE_VALUE_INIT = 'DELETE_VALUE_INIT' +export const DELETE_VALUE_SUCCESS = 'DELETE_VALUE_SUCCESS' +export const DELETE_VALUE_ERROR = 'DELETE_VALUE_ERROR' + +export const ACTIVATE_SET = 'ACTIVATE_SET' + +export const CREATE_SET = 'CREATE_SET' + +export const DELETE_SET_INIT = 'DELETE_SET_INIT' +export const DELETE_SET_SUCCESS = 'DELETE_SET_SUCCESS' +export const DELETE_SET_ERROR = 'DELETE_SET_ERROR' diff --git a/rdmo/projects/assets/js/interview/actions/interviewActions.js b/rdmo/projects/assets/js/interview/actions/interviewActions.js new file mode 100644 index 0000000000..b37c6f7838 --- /dev/null +++ b/rdmo/projects/assets/js/interview/actions/interviewActions.js @@ -0,0 +1,535 @@ +import { isEmpty, isNil } from 'lodash' + +import PageApi from '../api/PageApi' +import ProjectApi from '../api/ProjectApi' +import ValueApi from '../api/ValueApi' + +import { elementTypes } from 'rdmo/management/assets/js/constants/elements' + +import { updateProgress } from './projectActions' + +import { updateLocation } from '../utils/location' + +import { updateOptions } from '../utils/options' +import { initPage } from '../utils/page' +import { gatherSets, getDescendants, initSets } from '../utils/set' +import { activateFirstValue, gatherDefaultValues, initValues } from '../utils/value' +import { projectId } from '../utils/meta' + +import ValueFactory from '../factories/ValueFactory' +import SetFactory from '../factories/SetFactory' + +import { + NOOP, + FETCH_PAGE_INIT, + FETCH_PAGE_SUCCESS, + FETCH_PAGE_ERROR, + FETCH_NAVIGATION_INIT, + FETCH_NAVIGATION_SUCCESS, + FETCH_NAVIGATION_ERROR, + FETCH_OPTIONS_INIT, + FETCH_OPTIONS_SUCCESS, + FETCH_OPTIONS_ERROR, + FETCH_VALUES_INIT, + FETCH_VALUES_SUCCESS, + FETCH_VALUES_ERROR, + RESOLVE_CONDITION_INIT, + RESOLVE_CONDITION_SUCCESS, + RESOLVE_CONDITION_ERROR, + CREATE_VALUE, + UPDATE_VALUE, + STORE_VALUE_INIT, + STORE_VALUE_SUCCESS, + STORE_VALUE_ERROR, + DELETE_VALUE_INIT, + DELETE_VALUE_SUCCESS, + DELETE_VALUE_ERROR, + CREATE_SET, + DELETE_SET_INIT, + DELETE_SET_SUCCESS, + DELETE_SET_ERROR +} from './actionTypes' + +import { updateConfig } from 'rdmo/core/assets/js/actions/configActions' +import { addToPending, removeFromPending } from 'rdmo/core/assets/js/actions/pendingActions' + +export function fetchPage(pageId, back) { + const pendingId = 'fetchPage' + + return (dispatch, getState) => { + // store unsaved defaults on this page before loading the new page + gatherDefaultValues(getState().interview.page, getState().interview.values).forEach((value) => { + ValueApi.storeValue(projectId, value) + }) + + dispatch(addToPending(pendingId)) + dispatch(fetchPageInit()) + + if (pageId === 'done') { + updateLocation('done') + dispatch(fetchNavigation(null)) + dispatch(fetchPageSuccess(null, true)) + } else { + const promise = isNil(pageId) ? PageApi.fetchContinue(projectId) + : PageApi.fetchPage(projectId, pageId, back) + return promise + .then((page) => { + updateLocation(page.id) + + initPage(page) + + dispatch(fetchNavigation(page)) + dispatch(fetchValues(page)) + dispatch(fetchOptionsets(page)) + + dispatch(removeFromPending(pendingId)) + dispatch(fetchPageSuccess(page, false)) + }) + .catch((error) => { + dispatch(removeFromPending(pendingId)) + dispatch(fetchPageError(error)) + }) + } + } +} + +export function fetchPageInit() { + return {type: FETCH_PAGE_INIT} +} + +export function fetchPageSuccess(page, done) { + return {type: FETCH_PAGE_SUCCESS, page, done} +} + +export function fetchPageError(error) { + return {type: FETCH_PAGE_ERROR, error} +} + +export function fetchNavigation(page) { + const pendingId = `fetchNavigation/${page.id}` + + return (dispatch) => { + dispatch(addToPending(pendingId)) + dispatch(fetchNavigationInit()) + + return ProjectApi.fetchNavigation(projectId, page && page.section.id) + .then((navigation) => { + dispatch(removeFromPending(pendingId)) + dispatch(fetchNavigationSuccess(navigation)) + }) + .catch((error) => { + dispatch(removeFromPending(pendingId)) + dispatch(fetchNavigationError(error)) + }) + } +} + +export function fetchNavigationInit() { + return {type: FETCH_NAVIGATION_INIT} +} + +export function fetchNavigationSuccess(navigation) { + return {type: FETCH_NAVIGATION_SUCCESS, navigation} +} + +export function fetchNavigationError(error) { + return {type: FETCH_NAVIGATION_ERROR, error} +} + +export function fetchOptionsets(page) { + return (dispatch) => { + page.optionsets.filter((optionset) => (optionset.has_provider && !optionset.has_search)) + .forEach((optionset) => dispatch(fetchOptions(page, optionset))) + } +} + +export function fetchOptions(page, optionset) { + const pendingId = `fetchOptions/${page.id}/${optionset.id}` + + return (dispatch) => { + dispatch(addToPending(pendingId)) + dispatch(fetchOptionsInit()) + + return ProjectApi.fetchOptions(projectId, optionset.id) + .then((options) => { + updateOptions(page, optionset, options) + + dispatch(removeFromPending(pendingId)) + dispatch(fetchOptionsSuccess(page, optionset, options)) + }) + .catch((error) => { + dispatch(removeFromPending(pendingId)) + dispatch(fetchOptionsError(error)) + }) + } +} + +export function fetchOptionsInit() { + return {type: FETCH_OPTIONS_INIT} +} + +export function fetchOptionsSuccess(page) { + return {type: FETCH_OPTIONS_SUCCESS, page} +} + +export function fetchOptionsError(error) { + return {type: FETCH_OPTIONS_ERROR, error} +} + +export function fetchValues(page) { + const pendingId = `fetchValues/${page.id}` + + return (dispatch) => { + dispatch(addToPending(pendingId)) + dispatch(fetchValuesInit()) + return ValueApi.fetchValues(projectId, { attribute: page.attributes }) + .then((values) => { + const sets = gatherSets(values) + + initSets(sets, page) + initValues(sets, values, page) + + activateFirstValue(page, values) + + dispatch(removeFromPending(pendingId)) + dispatch(resolveConditions(page, sets)) + dispatch(fetchValuesSuccess(values, sets)) + }) + .catch((error) => { + dispatch(removeFromPending(pendingId)) + dispatch(fetchValuesError(error)) + }) + } +} + +export function fetchValuesInit() { + return {type: FETCH_VALUES_INIT} +} + +export function fetchValuesSuccess(values, sets) { + return {type: FETCH_VALUES_SUCCESS, values, sets} +} + +export function fetchValuesError(error) { + return {type: FETCH_VALUES_ERROR, error} +} + +export function resolveConditions(page, sets) { + return (dispatch) => { + // loop over set to evaluate conditions + sets.forEach((set) => { + page.questionsets.filter((questionset) => questionset.has_conditions) + .forEach((questionset) => dispatch(resolveCondition(questionset, set))) + + page.questions.filter((question) => question.has_conditions) + .forEach((question) => dispatch(resolveCondition(question, set))) + + page.optionsets.filter((optionset) => optionset.has_conditions) + .forEach((optionset) => dispatch(resolveCondition(optionset, set))) + }) + } +} + +export function resolveCondition(element, set) { + const pendingId = `resolveCondition/${element.model}/${element.id}/${set.set_prefix}/${set.set_index}` + + return (dispatch, getState) => { + dispatch(addToPending(pendingId)) + dispatch(resolveConditionInit()) + + return ProjectApi.resolveCondition(projectId, set, element) + .then((response) => { + const elementType = elementTypes[element.model] + const setIndex = getState().interview.sets.indexOf(set) + const results = { ...set[elementType], [element.id]: response.result } + + dispatch(removeFromPending(pendingId)) + dispatch(resolveConditionSuccess({ ...set, [elementType]: results }, setIndex)) + }) + .catch((error) => { + dispatch(removeFromPending(pendingId)) + dispatch(resolveConditionError(error)) + }) + } +} + +export function resolveConditionInit() { + return {type: RESOLVE_CONDITION_INIT} +} + +export function resolveConditionSuccess(set, setIndex) { + return {type: RESOLVE_CONDITION_SUCCESS, set, setIndex} +} + +export function resolveConditionError(error) { + return {type: RESOLVE_CONDITION_ERROR, error} +} + +export function storeValue(value) { + const pendingId = `storeValue/${value.attribute}/${value.set_prefix}/${value.set_index}/${value.collection_index}` + + if (value.pending) { + return {type: NOOP} + } else { + return (dispatch, getState) => { + const valueIndex = getState().interview.values.map((v) => v.id).indexOf(value.id) + const valueFile = value.file + const valueSuccess = value.success + + dispatch(addToPending(pendingId)) + dispatch(storeValueInit(valueIndex)) + + return ValueApi.storeValue(projectId, value) + .then((value) => { + const page = getState().interview.page + const sets = getState().interview.sets + const question = page.questions.find((question) => question.attribute === value.attribute) + const refresh = question && question.optionsets.some((optionset) => optionset.has_refresh) + + dispatch(fetchNavigation(page)) + dispatch(updateProgress()) + + if (refresh) { + // if the refresh flag is set, reload all values for the page, + // resolveConditions will be called in fetchValues + dispatch(fetchValues(page)) + } else { + dispatch(resolveConditions(page, sets)) + } + + // set the success flag and start the timeout to remove it. the flag is actually + // the stored timeout, so we can cancel any old timeout before starting the a new + // one in order to prolong the time the indicator is show with each save + clearTimeout(valueSuccess) + value.success = setTimeout(() => { + dispatch(updateValue(value, {success: false}, false)) + }, 1000) + + // check if there is a file or if a filename is set (when the file was just erased) + if (isNil(valueFile) && isNil(value.file_name)) { + dispatch(removeFromPending(pendingId)) + dispatch(storeValueSuccess(value, valueIndex)) + } else { + // upload file after the value is created + return ValueApi.storeFile(projectId, value, valueFile) + .then((value) => { + dispatch(removeFromPending(pendingId)) + dispatch(storeValueSuccess(value, valueIndex)) + }) + .catch((error) => { + dispatch(removeFromPending(pendingId)) + dispatch(storeValueError(error, valueIndex)) + }) + } + }) + .catch((error) => { + dispatch(removeFromPending(pendingId)) + dispatch(storeValueError(error, valueIndex)) + }) + } + } +} + +export function storeValueInit(valueIndex) { + return {type: STORE_VALUE_INIT, valueIndex} +} + +export function storeValueSuccess(value, valueIndex) { + return {type: STORE_VALUE_SUCCESS, value, valueIndex} +} + +export function storeValueError(error, valueIndex) { + return {type: STORE_VALUE_ERROR, error, valueIndex} +} + +export function createValue(attrs, store) { + const value = ValueFactory.create(attrs) + + // focus the new value + value.focus = true + + if (isNil(store)) { + return {type: CREATE_VALUE, value} + } else { + return storeValue(value) + } +} + +export function updateValue(value, attrs, store = true) { + if (store) { + return storeValue(ValueFactory.update(value, attrs)) + } else { + return {type: UPDATE_VALUE, value, attrs} + } +} + +export function deleteValue(value) { + const pendingId = `deleteValue/${value.id}` + + if (value.pending) { + return {type: NOOP} + } else { + return (dispatch, getState) => { + dispatch(addToPending(pendingId)) + dispatch(deleteValueInit(value)) + + if (isNil(value.id)) { + return dispatch(deleteValueSuccess(value)) + } else { + return ValueApi.deleteValue(projectId, value) + .then(() => { + const page = getState().interview.page + const sets = getState().interview.sets + const question = page.questions.find((question) => question.attribute === value.attribute) + const refresh = question.optionsets.some((optionset) => optionset.has_refresh) + + dispatch(fetchNavigation(page)) + dispatch(updateProgress()) + + if (refresh) { + // if the refresh flag is set, reload all values for the page, + // resolveConditions will be called in fetchValues + dispatch(fetchValues(page)) + } else { + dispatch(resolveConditions(page, sets)) + } + + dispatch(removeFromPending(pendingId)) + dispatch(deleteValueSuccess(value)) + }) + .catch((errors) => { + dispatch(removeFromPending(pendingId)) + dispatch(deleteValueError(errors)) + }) + } + } + } +} + +export function deleteValueInit(value) { + return {type: DELETE_VALUE_INIT, value} +} + +export function deleteValueSuccess(value) { + return {type: DELETE_VALUE_SUCCESS, value} +} + +export function deleteValueError(errors) { + return {type: DELETE_VALUE_ERROR, errors} +} + +export function activateSet(set) { + if (isEmpty(set.set_prefix)) { + return updateConfig('page.currentSetIndex', set.set_index, true) + } else { + return { type: NOOP } + } +} + +export function createSet(attrs) { + return (dispatch, getState) => { + // create a new set + const set = SetFactory.create(attrs) + + // create a value for the text if the page has an attribute + const value = isNil(attrs.attribute) ? null : ValueFactory.create(attrs) + + // create an action to be called immediately or after saving the value + const createSetSuccess = (value) => { + dispatch(activateSet(set)) + + const state = getState().interview + + const page = state.page + const sets = [...state.sets, set] + const values = isNil(value) ? [...state.values] : [...state.values, value] + + initSets(sets, page) + initValues(sets, values, page) + + return dispatch({type: CREATE_SET, values, sets}) + } + + if (isNil(value)) { + return createSetSuccess() + } else { + return dispatch(storeValue(value)).then((action) => { + if (action.type === STORE_VALUE_SUCCESS) { + createSetSuccess(action.value) + } + }) + } + } +} + +export function updateSet(setValue, attrs) { + return storeValue(ValueFactory.update(setValue, attrs)) +} + +export function deleteSet(set, setValue) { + const pendingId = `deleteSet/${set.set_prefix}/${set.set_index}` + + return (dispatch, getState) => { + dispatch(addToPending(pendingId)) + dispatch(deleteSetInit()) + + if (isNil(setValue)) { + // gather all values for this set and it's descendants + const values = getDescendants(getState().interview.values, set) + + return Promise.all(values.map((value) => ValueApi.deleteValue(projectId, value))) + .then(() => { + dispatch(removeFromPending(pendingId)) + dispatch(deleteSetSuccess(set)) + }) + .catch((errors) => { + dispatch(removeFromPending(pendingId)) + dispatch(deleteSetError(errors)) + }) + } else { + return ValueApi.deleteSet(projectId, setValue) + .then(() => { + const page = getState().interview.page + + dispatch(fetchNavigation(page)) + dispatch(updateProgress()) + + const sets = getState().interview.sets.filter((s) => (s.set_prefix == set.set_prefix)) + + if (sets.length > 1) { + const index = sets.indexOf(set) + if (index > 0) { + dispatch(activateSet(sets[index - 1])) + } else if (index == 0) { + dispatch(activateSet(sets[1])) + } + } + + dispatch(removeFromPending(pendingId)) + dispatch(deleteSetSuccess(set)) + }) + .catch((errors) => { + dispatch(removeFromPending(pendingId)) + dispatch(deleteSetError(errors)) + }) + } + } +} + +export function deleteSetInit() { + return {type: DELETE_SET_INIT} +} + +export function deleteSetSuccess(set) { + return (dispatch, getState) => { + // again, gather all values for this set and it's descendants + const sets = getDescendants(getState().interview.sets, set) + const values = getDescendants(getState().interview.values, set) + + return dispatch({type: DELETE_SET_SUCCESS, sets, values}) + } +} + +export function deleteSetError(errors) { + return {type: DELETE_SET_ERROR, errors} +} diff --git a/rdmo/projects/assets/js/interview/actions/projectActions.js b/rdmo/projects/assets/js/interview/actions/projectActions.js new file mode 100644 index 0000000000..4ce3a3baa5 --- /dev/null +++ b/rdmo/projects/assets/js/interview/actions/projectActions.js @@ -0,0 +1,104 @@ +import ProjectApi from '../api/ProjectApi' + +import { projectId } from '../utils/meta' + +import { + FETCH_OVERVIEW_INIT, + FETCH_OVERVIEW_SUCCESS, + FETCH_OVERVIEW_ERROR, + FETCH_PROGRESS_INIT, + FETCH_PROGRESS_SUCCESS, + FETCH_PROGRESS_ERROR, + UPDATE_PROGRESS_INIT, + UPDATE_PROGRESS_SUCCESS, + UPDATE_PROGRESS_ERROR, +} from './actionTypes' + +import { addToPending, removeFromPending } from 'rdmo/core/assets/js/actions/pendingActions' + +export function fetchOverview() { + return (dispatch) => { + dispatch(addToPending('fetchOverview')) + dispatch(fetchOverviewInit()) + + return ProjectApi.fetchOverview(projectId) + .then((overview) => { + dispatch(removeFromPending('fetchOverview')) + dispatch(fetchOverviewSuccess(overview)) + }) + .catch((error) => { + dispatch(removeFromPending('fetchOverview')) + dispatch(fetchOverviewError(error)) + }) + } +} + +export function fetchOverviewInit() { + return {type: FETCH_OVERVIEW_INIT} +} + +export function fetchOverviewSuccess(overview) { + return {type: FETCH_OVERVIEW_SUCCESS, overview} +} + +export function fetchOverviewError(error) { + return {type: FETCH_OVERVIEW_ERROR, error} +} + +export function fetchProgress() { + return (dispatch) => { + dispatch(addToPending('fetchProgress')) + dispatch(fetchProgressInit()) + + return ProjectApi.fetchProgress(projectId) + .then((progress) => { + dispatch(removeFromPending('fetchProgress')) + dispatch(fetchProgressSuccess(progress)) + }) + .catch((error) => { + dispatch(removeFromPending('fetchProgress')) + dispatch(fetchProgressError(error)) + }) + } +} + +export function fetchProgressInit() { + return {type: FETCH_PROGRESS_INIT} +} + +export function fetchProgressSuccess(progress) { + return {type: FETCH_PROGRESS_SUCCESS, progress} +} + +export function fetchProgressError(error) { + return {type: FETCH_PROGRESS_ERROR, error} +} + +export function updateProgress() { + return (dispatch) => { + dispatch(addToPending('updateProgress')) + dispatch(updateProgressInit()) + + return ProjectApi.updateProgress(projectId) + .then((progress) => { + dispatch(removeFromPending('updateProgress')) + dispatch(updateProgressSuccess(progress)) + }) + .catch((error) => { + dispatch(removeFromPending('updateProgress')) + dispatch(updateProgressError(error)) + }) + } +} + +export function updateProgressInit() { + return {type: UPDATE_PROGRESS_INIT} +} + +export function updateProgressSuccess(progress) { + return {type: UPDATE_PROGRESS_SUCCESS, progress} +} + +export function updateProgressError(error) { + return {type: UPDATE_PROGRESS_ERROR, error} +} diff --git a/rdmo/projects/assets/js/interview/api/PageApi.js b/rdmo/projects/assets/js/interview/api/PageApi.js new file mode 100644 index 0000000000..36035c8c34 --- /dev/null +++ b/rdmo/projects/assets/js/interview/api/PageApi.js @@ -0,0 +1,21 @@ +import { isNil } from 'lodash' + +import BaseApi from 'rdmo/core/assets/js/api/BaseApi' + +class ProjectsApi extends BaseApi { + + static fetchPage(projectId, pageId, back) { + if (isNil(back)) { + return this.get(`/api/v1/projects/projects/${projectId}/pages/${pageId}/`) + } else { + return this.get(`/api/v1/projects/projects/${projectId}/pages/${pageId}/?back=true`) + } + } + + static fetchContinue(projectId) { + return this.get(`/api/v1/projects/projects/${projectId}/pages/continue/`) + } + +} + +export default ProjectsApi diff --git a/rdmo/projects/assets/js/interview/api/ProjectApi.js b/rdmo/projects/assets/js/interview/api/ProjectApi.js new file mode 100644 index 0000000000..4eb136bf3a --- /dev/null +++ b/rdmo/projects/assets/js/interview/api/ProjectApi.js @@ -0,0 +1,47 @@ +import { isNil, last } from 'lodash' + +import { encodeParams } from 'rdmo/core/assets/js/utils/api' + +import BaseApi from 'rdmo/core/assets/js/api/BaseApi' + +class ProjectsApi extends BaseApi { + + static fetchOverview(projectId) { + return this.get(`/api/v1/projects/projects/${projectId}/overview/`) + } + + static fetchNavigation(projectId, page_id) { + if (isNil(page_id)) { + return this.get(`/api/v1/projects/projects/${projectId}/navigation/`) + } else { + return this.get(`/api/v1/projects/projects/${projectId}/navigation/${page_id}/`) + } + } + + static fetchProgress(projectId) { + return this.get(`/api/v1/projects/projects/${projectId}/progress/`) + } + + static updateProgress(projectId) { + return this.post(`/api/v1/projects/projects/${projectId}/progress/`) + } + + static fetchOptions(projectId, optionsetId, searchText) { + const params = { optionset: optionsetId, search: searchText || '' } + return this.get(`/api/v1/projects/projects/${projectId}/options/?${encodeParams(params)}`) + } + + static resolveCondition(projectId, set, element) { + const model = last(element.model.split('.')) + const params = { + set_prefix: set.set_prefix, + set_index: set.set_index, + [model]: element.id + } + + return this.get(`/api/v1/projects/projects/${projectId}/resolve/?${encodeParams(params)}`) + } + +} + +export default ProjectsApi diff --git a/rdmo/projects/assets/js/interview/api/ValueApi.js b/rdmo/projects/assets/js/interview/api/ValueApi.js new file mode 100644 index 0000000000..208bbca611 --- /dev/null +++ b/rdmo/projects/assets/js/interview/api/ValueApi.js @@ -0,0 +1,38 @@ +import BaseApi from 'rdmo/core/assets/js/api/BaseApi' +import { encodeParams } from 'rdmo/core/assets/js/utils/api' +import isUndefined from 'lodash/isUndefined' + +class ValueApi extends BaseApi { + + static fetchValues(projectId, params) { + return this.get(`/api/v1/projects/projects/${projectId}/values/?${encodeParams(params)}`) + } + + static storeValue(projectId, value) { + if (isUndefined(value.id)) { + return this.post(`/api/v1/projects/projects/${projectId}/values/`, value) + } else { + return this.put(`/api/v1/projects/projects/${projectId}/values/${value.id}/`, value) + } + } + + static storeFile(projectId, value, file) { + const formData = new FormData() + formData.append('file', file) + + return this.postFormData(`/api/v1/projects/projects/${projectId}/values/${value.id}/file/`, formData) + } + + static deleteValue(projectId, value) { + if (!isUndefined(value.id)) { + return this.delete(`/api/v1/projects/projects/${projectId}/values/${value.id}/`) + } + } + + static deleteSet(projectId, value) { + return this.delete(`/api/v1/projects/projects/${projectId}/values/${value.id}/set/`) + } + +} + +export default ValueApi diff --git a/rdmo/projects/assets/js/interview/components/main/Breadcrump.js b/rdmo/projects/assets/js/interview/components/main/Breadcrump.js new file mode 100644 index 0000000000..9479b63873 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/Breadcrump.js @@ -0,0 +1,44 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { baseUrl } from 'rdmo/core/assets/js/utils/meta' + +const Breadcrump = ({ overview, page, fetchPage }) => { + + const handleClick = (event) => { + event.preventDefault() + fetchPage(page.section.first) + } + + return ( + + ) +} + +Breadcrump.propTypes = { + overview: PropTypes.object.isRequired, + page: PropTypes.object, + fetchPage: PropTypes.func.isRequired +} + +export default Breadcrump diff --git a/rdmo/projects/assets/js/interview/components/main/Done.js b/rdmo/projects/assets/js/interview/components/main/Done.js new file mode 100644 index 0000000000..e34202b28e --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/Done.js @@ -0,0 +1,35 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { baseUrl } from 'rdmo/core/assets/js/utils/meta' + +import { projectId } from '../../utils/meta' + +import Html from 'rdmo/core/assets/js/components/Html' + +const Done = ({ templates }) => { + + const projectUrl = `${baseUrl}/projects/${projectId}/` + const answersUrl = `${baseUrl}/projects/${projectId}/answers/` + + return ( + <> + + +

+ {gettext('View answers')} +

+ +

+ {gettext('Back to project overview')} +

+ + ) +} + +Done.propTypes = { + templates: PropTypes.object.isRequired, + overview: PropTypes.object.isRequired +} + +export default Done diff --git a/rdmo/projects/assets/js/interview/components/main/Errors.js b/rdmo/projects/assets/js/interview/components/main/Errors.js new file mode 100644 index 0000000000..9914553a17 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/Errors.js @@ -0,0 +1,44 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { baseUrl } from 'rdmo/core/assets/js/utils/meta' + +import Html from 'rdmo/core/assets/js/components/Html' + +import { projectId } from '../../utils/meta' + +const Errors = ({ templates, errors }) => { + const projectUrl = `${baseUrl}/projects/${projectId}/` + + return ( + <> + + +
    + { + errors.map((error, errorIndex) => ( +
  • + {error.actionType} + {error.statusText && <>: {error.statusText}} + {error.status && <>({error.status})} +
  • + )) + } +
+ +

+ window.location.reload()}>{gettext('Reload page')} +

+

+ {gettext('Back to project overview')} +

+ + ) +} + +Errors.propTypes = { + templates: PropTypes.object.isRequired, + errors: PropTypes.array.isRequired +} + +export default Errors diff --git a/rdmo/projects/assets/js/interview/components/main/page/Page.js b/rdmo/projects/assets/js/interview/components/main/page/Page.js new file mode 100644 index 0000000000..8a6155c8cf --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/Page.js @@ -0,0 +1,110 @@ +import React from 'react' +import PropTypes from 'prop-types' +import get from 'lodash/get' +import { isNil } from 'lodash' + +import Html from 'rdmo/core/assets/js/components/Html' + +import Question from '../question/Question' +import QuestionSet from '../questionset/QuestionSet' + +import PageButtons from './PageButtons' +import PageHead from './PageHead' + +const Page = ({ config, templates, overview, page, sets, values, fetchPage, + createValue, updateValue, deleteValue, + activateSet, createSet, updateSet, deleteSet }) => { + + const currentSetPrefix = '' + const currentSetIndex = page.is_collection ? get(config, 'page.currentSetIndex', 0) : 0 + const currentSet = sets.find((set) => (set.set_prefix == currentSetPrefix && set.set_index == currentSetIndex)) || + sets.find((set) => (set.set_prefix == currentSetPrefix && set.set_index == 0)) // sanity check + + const isManager = (overview.is_superuser || overview.is_editor || overview.is_reviewer) + + return ( +
+

{page.title}

+ + (set.set_prefix == currentSetPrefix))} + values={isNil(page.attribute) ? [] : values.filter((value) => (value.attribute == page.attribute))} + currentSet={currentSet} + activateSet={activateSet} + createSet={createSet} + updateSet={updateSet} + deleteSet={deleteSet} + /> +
+ { + currentSet && ( + page.elements.map((element, elementIndex) => { + if (element.model == 'questions.questionset') { + return ( + element.attributes.includes(value.attribute))} + disabled={overview.read_only} + isManager={isManager} + parentSet={currentSet} + createSet={createSet} + updateSet={updateSet} + deleteSet={deleteSet} + createValue={createValue} + updateValue={updateValue} + deleteValue={deleteValue} + /> + ) + } else { + return ( + ( + value.attribute == element.attribute && + value.set_prefix == currentSetPrefix && + value.set_index == currentSetIndex + ))} + disabled={overview.read_only} + isManager={isManager} + currentSet={currentSet} + createValue={createValue} + updateValue={updateValue} + deleteValue={deleteValue} + /> + ) + } + }) + ) + } +
+ + +
+ ) +} + +Page.propTypes = { + config: PropTypes.object.isRequired, + templates: PropTypes.object.isRequired, + overview: PropTypes.object.isRequired, + page: PropTypes.object.isRequired, + sets: PropTypes.array.isRequired, + values: PropTypes.array.isRequired, + fetchPage: PropTypes.func.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired, + activateSet: PropTypes.func.isRequired, + createSet: PropTypes.func.isRequired, + updateSet: PropTypes.func.isRequired, + deleteSet: PropTypes.func.isRequired +} + +export default Page diff --git a/rdmo/projects/assets/js/interview/components/main/page/PageButtons.js b/rdmo/projects/assets/js/interview/components/main/page/PageButtons.js new file mode 100644 index 0000000000..2edc0d5358 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/PageButtons.js @@ -0,0 +1,38 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const PageButtons = ({ page, fetchPage }) => { + return ( + <> +
+
+ + {' '} + { + page.next_page ? ( + + ) : ( + + ) + } +
+
+ + ) +} + +PageButtons.propTypes = { + page: PropTypes.object.isRequired, + fetchPage: PropTypes.func.isRequired +} + +export default PageButtons diff --git a/rdmo/projects/assets/js/interview/components/main/page/PageHead.js b/rdmo/projects/assets/js/interview/components/main/page/PageHead.js new file mode 100644 index 0000000000..57f50e430b --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/PageHead.js @@ -0,0 +1,135 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { capitalize, isNil, last } from 'lodash' + +import Html from 'rdmo/core/assets/js/components/Html' +import useModal from 'rdmo/core/assets/js/hooks/useModal' + +import PageHeadDeleteModal from './PageHeadDeleteModal' +import PageHeadFormModal from './PageHeadFormModal' + +const PageHead = ({ templates, page, sets, values, currentSet, activateSet, createSet, updateSet, deleteSet }) => { + + const currentSetValue = isNil(currentSet) ? null : ( + values.find((value) => ( + value.set_prefix == currentSet.set_prefix && value.set_index == currentSet.set_index + )) + ) + + const {show: showCreateModal, open: openCreateModal, close: closeCreateModal} = useModal() + const {show: showUpdateModal, open: openUpdateModal, close: closeUpdateModal} = useModal() + const {show: showDeleteModal, open: openDeleteModal, close: closeDeleteModal} = useModal() + + const handleActivateSet = (event, set) => { + event.preventDefault() + if (set.set_index != currentSet.set_index) { + activateSet(set) + } + } + + const handleCreateSet = (text) => { + createSet({ + attribute: page.attribute, + set_index: last(sets) ? last(sets).set_index + 1 : 0, + set_collection: page.is_collection, + text + }) + closeCreateModal() + } + + const handleUpdateSet = (text) => { + updateSet(currentSetValue, { text }) + closeUpdateModal() + } + + const handleDeleteSet = () => { + deleteSet(currentSet, currentSetValue) + closeDeleteModal() + } + + return page.is_collection && ( +
+ + + { + currentSet ? ( + <> + +
+ { + page.attribute && ( +
+ + ) : ( + + ) + } + + + { + currentSetValue && ( + + ) + } + +
+ ) +} + +PageHead.propTypes = { + templates: PropTypes.object.isRequired, + page: PropTypes.object.isRequired, + sets: PropTypes.array.isRequired, + values: PropTypes.array.isRequired, + currentSet: PropTypes.object, + activateSet: PropTypes.func.isRequired, + createSet: PropTypes.func.isRequired, + updateSet: PropTypes.func.isRequired, + deleteSet: PropTypes.func.isRequired +} + +export default PageHead diff --git a/rdmo/projects/assets/js/interview/components/main/page/PageHeadDeleteModal.js b/rdmo/projects/assets/js/interview/components/main/page/PageHeadDeleteModal.js new file mode 100644 index 0000000000..bb82035f9a --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/PageHeadDeleteModal.js @@ -0,0 +1,24 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Modal from 'rdmo/core/assets/js/components/Modal' + +const PageHeadDeleteModal = ({ title, show, onClose, onSubmit }) => { + return ( + +

{gettext('You are about to permanently delete this tab.')}

+

{gettext('This includes all given answers for this tab on all pages, not just this one.')}

+

{gettext('This action cannot be undone!')}

+
+ ) +} + +PageHeadDeleteModal.propTypes = { + title: PropTypes.string.isRequired, + show: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +} + +export default PageHeadDeleteModal diff --git a/rdmo/projects/assets/js/interview/components/main/page/PageHeadFormModal.js b/rdmo/projects/assets/js/interview/components/main/page/PageHeadFormModal.js new file mode 100644 index 0000000000..d5391b062f --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/PageHeadFormModal.js @@ -0,0 +1,91 @@ +import React, { useState, useRef, useEffect } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty, isNil } from 'lodash' + +import Modal from 'rdmo/core/assets/js/components/Modal' +import useFocusEffect from '../../../hooks/useFocusEffect' + + +const PageHeadFormModal = ({ title, show, initial, onClose, onSubmit }) => { + + const ref = useRef(null) + const [inputValue, setInputValue] = useState('') + const [hasError, setHasError] = useState(false) + const submitLabel = isEmpty(initial) ? gettext('Create') : gettext('Update') + const submitProps = { + className: classNames('btn', { + 'btn-success': isEmpty(initial), + 'btn-primary': !isEmpty(initial), + }) + } + + const handleSubmit = () => { + if (isEmpty(inputValue) && !isNil(initial)) { + setHasError(true) + } else { + onSubmit(inputValue) + } + } + + // update the inputValue + useEffect(() => { + if (show) { + setInputValue(initial || '') + } + }, [show]) + + // remove the hasError flag if an inputValue is entered + useEffect(() => { + if (!isEmpty(inputValue)) { + setHasError(false) + } + }, [inputValue]) + + // focus when the modal is shown + useFocusEffect(ref, show) + + return ( + + { + isNil(initial) ? ( +
+ {gettext('You can add a new tab using the create button.')} +
+ ) : ( +
+ + setInputValue(event.target.value)} + onKeyPress={(event) => { + if (event.code === 'Enter') { + handleSubmit() + } + }} + /> + +

{gettext('Please give the tab a meaningful name.')}

+
+ ) + } +
+ ) +} + +PageHeadFormModal.propTypes = { + title: PropTypes.string.isRequired, + show: PropTypes.bool.isRequired, + initial: PropTypes.string, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired +} + +export default PageHeadFormModal diff --git a/rdmo/projects/assets/js/interview/components/main/question/Question.js b/rdmo/projects/assets/js/interview/components/main/question/Question.js new file mode 100644 index 0000000000..cea3c70ff4 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/Question.js @@ -0,0 +1,51 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { checkQuestion } from '../../../utils/page' + +import QuestionAddValueHelp from './QuestionAddValueHelp' +import QuestionHelp from './QuestionHelp' +import QuestionHelpTemplate from './QuestionHelpTemplate' +import QuestionManagement from './QuestionManagement' +import QuestionOptional from './QuestionOptional' +import QuestionText from './QuestionText' +import QuestionWarning from './QuestionWarning' +import QuestionWidget from './QuestionWidget' + +const Question = ({ templates, question, values, disabled, isManager, + currentSet, createValue, updateValue, deleteValue }) => { + return checkQuestion(question, currentSet) && ( +
+ + + + + + + + +
+ ) +} + +Question.propTypes = { + templates: PropTypes.object.isRequired, + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + disabled: PropTypes.bool.isRequired, + isManager: PropTypes.bool.isRequired, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired, +} + +export default Question diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionAddValue.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionAddValue.js new file mode 100644 index 0000000000..19ca71f249 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionAddValue.js @@ -0,0 +1,34 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { capitalize, maxBy } from 'lodash' + +const AddValue = ({ question, values, currentSet, disabled, createValue }) => { + const handleClick = () => { + const lastValue = maxBy(values, (v) => v.collection_index) + const collectionIndex = lastValue ? lastValue.collection_index + 1 : 0 + + createValue({ + attribute: question.attribute, + set_prefix: currentSet.set_prefix, + set_index: currentSet.set_index, + collection_index: collectionIndex, + set_collection: question.set_collection + }) + } + + return !disabled && question.is_collection && ( + + ) +} + +AddValue.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + currentSet: PropTypes.object.isRequired, + disabled: PropTypes.bool.isRequired, + createValue: PropTypes.func.isRequired +} + +export default AddValue diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionAddValueHelp.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionAddValueHelp.js new file mode 100644 index 0000000000..8a6428ee32 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionAddValueHelp.js @@ -0,0 +1,17 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Html from 'rdmo/core/assets/js/components/Html' + +const QuestionAddValueHelp = ({ templates, question }) => { + return question.is_collection && ( + + ) +} + +QuestionAddValueHelp.propTypes = { + templates: PropTypes.object.isRequired, + question: PropTypes.object.isRequired, +} + +export default QuestionAddValueHelp diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionDefault.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionDefault.js new file mode 100644 index 0000000000..780e9b07df --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionDefault.js @@ -0,0 +1,19 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { isDefaultValue } from '../../../utils/value' + +const QuestionDefault = ({ question, value }) => { + return isDefaultValue(question, value) && ( +
+ {gettext('Default')} +
+ ) +} + +QuestionDefault.propTypes = { + question: PropTypes.object.isRequired, + value: PropTypes.object.isRequired +} + +export default QuestionDefault diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionEraseValue.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionEraseValue.js new file mode 100644 index 0000000000..720e30d8ec --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionEraseValue.js @@ -0,0 +1,23 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const QuestionEraseValue = ({ value, disabled, updateValue }) => { + const handleEraseValue = () => { + updateValue(value, {}) + } + + return !disabled && ( + + ) +} + +QuestionEraseValue.propTypes = { + value: PropTypes.object.isRequired, + disabled: PropTypes.bool.isRequired, + updateValue: PropTypes.func.isRequired +} + +export default QuestionEraseValue diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionError.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionError.js new file mode 100644 index 0000000000..a575f40845 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionError.js @@ -0,0 +1,39 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { isNil } from 'lodash' + +const getMessage = (error) => { + if (error.constructor.name === 'ValidationError') { + if (error.errors.conflict) { + return gettext('This field could not be saved, since somebody else did so while you were editing.' + + ' You will need to reload the page to make changes, but your input will be overwritten.') + } else if (error.errors.quota) { + return gettext('You reached the file quota for this project.') + } else if (error.errors.text) { + return error.errors.text + } + } else if (error.constructor.name === 'ApiError') { + if (error.status === 404) { + return gettext('This field could not be saved, since somebody else removed it while you were editing.' + + ' You will need to reload the page to proceed, but your input will be lost.') + } else { + return error.errors.api + } + } else { + return gettext('An unknown error occurred, please contact support') + } +} + +const QuestionError = ({ value }) => { + return !isNil(value) && !isNil(value.error) && ( +
    +
  • {getMessage(value.error)}
  • +
+ ) +} + +QuestionError.propTypes = { + value: PropTypes.object +} + +export default QuestionError diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionHelp.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionHelp.js new file mode 100644 index 0000000000..eec017251a --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionHelp.js @@ -0,0 +1,20 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' + +import Html from 'rdmo/core/assets/js/components/Html' + +const QuestionHelp = ({ question }) => { + const classnames = classNames({ + 'help-text': true, + 'text-muted': question.is_optional + }) + + return +} + +QuestionHelp.propTypes = { + question: PropTypes.object.isRequired +} + +export default QuestionHelp diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionHelpTemplate.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionHelpTemplate.js new file mode 100644 index 0000000000..bcf3ef469f --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionHelpTemplate.js @@ -0,0 +1,14 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Html from 'rdmo/core/assets/js/components/Html' + +const QuestionHelpTemplate = ({ templates }) => { + return +} + +QuestionHelpTemplate.propTypes = { + templates: PropTypes.object.isRequired +} + +export default QuestionHelpTemplate diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionManagement.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionManagement.js new file mode 100644 index 0000000000..646ab5f5da --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionManagement.js @@ -0,0 +1,28 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { baseUrl } from 'rdmo/core/assets/js/utils/meta' + +const QuestionManagement = ({ question, isManager }) => { + return isManager && ( + + ) +} + +QuestionManagement.propTypes = { + question: PropTypes.object.isRequired, + isManager: PropTypes.bool.isRequired, +} + +export default QuestionManagement diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionOptional.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionOptional.js new file mode 100644 index 0000000000..8b8c23a6ed --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionOptional.js @@ -0,0 +1,16 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const QuestionOptional = ({ question }) => { + return question.is_optional && ( +
+ {gettext('Optional')} +
+ ) +} + +QuestionOptional.propTypes = { + question: PropTypes.object.isRequired +} + +export default QuestionOptional diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionRemoveValue.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionRemoveValue.js new file mode 100644 index 0000000000..805c2616f3 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionRemoveValue.js @@ -0,0 +1,20 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const QuestionRemoveValue = ({ question, values, value, disabled, deleteValue }) => { + return !disabled && (question.is_collection || values.length > 1) && ( + + ) +} + +QuestionRemoveValue.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + value: PropTypes.object.isRequired, + disabled: PropTypes.bool.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default QuestionRemoveValue diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionSuccess.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionSuccess.js new file mode 100644 index 0000000000..831e6c7b3f --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionSuccess.js @@ -0,0 +1,19 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' + +const QuestionSuccess = ({ value }) => { + return ( +
+ +
+ ) +} + +QuestionSuccess.propTypes = { + value: PropTypes.object.isRequired, +} + +export default QuestionSuccess diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionText.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionText.js new file mode 100644 index 0000000000..4d632d2111 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionText.js @@ -0,0 +1,22 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' + +const QuestionText = ({ question }) => { + const classnames = classNames({ + 'form-label': true, + 'text-muted': question.is_optional + }) + + return ( +
+ {question.text} +
+ ) +} + +QuestionText.propTypes = { + question: PropTypes.object.isRequired +} + +export default QuestionText diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionUnit.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionUnit.js new file mode 100644 index 0000000000..fff3f16848 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionUnit.js @@ -0,0 +1,33 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { isUndefined } from 'lodash' + +const QuestionUnit = ({ question, inputValue }) => { + return (question.unit || !isUndefined(inputValue)) && ( +
+ { + !isUndefined(inputValue) && ( + <> + {inputValue} + {' '} + + ) + } + { + question.unit && !isUndefined(inputValue) && (' ') + } + { + question.unit && ( + {question.unit} + ) + } +
+ ) +} + +QuestionUnit.propTypes = { + question: PropTypes.object.isRequired, + inputValue: PropTypes.string, +} + +export default QuestionUnit diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionWarning.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionWarning.js new file mode 100644 index 0000000000..dfc91b8a49 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionWarning.js @@ -0,0 +1,18 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Html from 'rdmo/core/assets/js/components/Html' + +const QuestionWarning = ({ templates, question, values }) => { + return !question.is_collection && values.length > 1 && ( + + ) +} + +QuestionWarning.propTypes = { + templates: PropTypes.object.isRequired, + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired +} + +export default QuestionWarning diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionWidget.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionWidget.js new file mode 100644 index 0000000000..7d5e55c839 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionWidget.js @@ -0,0 +1,49 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import CheckboxWidget from '../widget/CheckboxWidget' +import DateWidget from '../widget/DateWidget' +import FileWidget from '../widget/FileWidget' +import RadioWidget from '../widget/RadioWidget' +import RangeWidget from '../widget/RangeWidget' +import SelectWidget from '../widget/SelectWidget' +import TextWidget from '../widget/TextWidget' +import TextareaWidget from '../widget/TextareaWidget' +import YesNoWidget from '../widget/YesNoWidget' + +const QuestionWidget = (props) => { + switch (props.question.widget_type) { + case 'checkbox': + return + case 'date': + return + case 'file': + return + case 'radio': + return + case 'range': + return + case 'select': + case 'autocomplete': + return + case 'freeautocomplete': + return + case 'text': + return + case 'textarea': + return + case 'yesno': + return + default: + return null + } +} + +QuestionWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default QuestionWidget diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSet.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSet.js new file mode 100644 index 0000000000..cf52efc93f --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSet.js @@ -0,0 +1,113 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { checkQuestionSet } from '../../../utils/page' +import { getChildPrefix } from '../../../utils/set' + +import Question from '../question/Question' + +import QuestionSetAddSet from './QuestionSetAddSet' +import QuestionSetAddSetHelp from './QuestionSetAddSetHelp' +import QuestionSetHelp from './QuestionSetHelp' +import QuestionSetHelpTemplate from './QuestionSetHelpTemplate' +import QuestionSetRemoveSet from './QuestionSetRemoveSet' + +const QuestionSet = ({ templates, questionset, sets, values, disabled, isManager, + parentSet, createSet, updateSet, deleteSet, + createValue, updateValue, deleteValue }) => { + + const setPrefix = getChildPrefix(parentSet) + + const currentSets = sets.filter((set) => ( + set.set_prefix == setPrefix + )) + + return checkQuestionSet(questionset, parentSet) && ( +
+
+ {questionset.title} +
+ + + +
+ { + currentSets.map((set, setIndex) => ( +
+
+ +
+
+ { + currentSets && ( + questionset.elements.map((element, elementIndex) => { + if (element.model == 'questions.questionset') { + return ( + element.attributes.includes(value.attribute))} + disabled={disabled} + isManager={isManager} + parentSet={set} + createSet={createSet} + updateSet={updateSet} + deleteSet={deleteSet} + createValue={createValue} + updateValue={updateValue} + deleteValue={deleteValue} + /> + ) + } else { + return ( + ( + value.attribute == element.attribute && + value.set_prefix == set.set_prefix && + value.set_index == set.set_index + ))} + disabled={disabled} + isManager={isManager} + currentSet={set} + createValue={createValue} + updateValue={updateValue} + deleteValue={deleteValue} + /> + ) + } + }) + ) + } +
+
+ )) + } +
+ + +
+ ) +} + +QuestionSet.propTypes = { + templates: PropTypes.object.isRequired, + questionset: PropTypes.object.isRequired, + sets: PropTypes.array.isRequired, + values: PropTypes.array.isRequired, + disabled: PropTypes.bool.isRequired, + isManager: PropTypes.bool.isRequired, + parentSet: PropTypes.object.isRequired, + createSet: PropTypes.func.isRequired, + updateSet: PropTypes.func.isRequired, + deleteSet: PropTypes.func.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default QuestionSet diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetAddSet.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetAddSet.js new file mode 100644 index 0000000000..2141af09eb --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetAddSet.js @@ -0,0 +1,30 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { capitalize, maxBy } from 'lodash' + +const QuestionSetAddSet = ({ questionset, sets, setPrefix, createSet }) => { + const handleClick = () => { + const lastSet = maxBy(sets, (s) => s.set_index) + const setIndex = lastSet ? lastSet.set_index + 1 : 0 + + createSet({ + set_prefix: setPrefix, + set_index: setIndex + }) + } + + return questionset.is_collection && ( + + ) +} + +QuestionSetAddSet.propTypes = { + questionset: PropTypes.object.isRequired, + sets: PropTypes.array.isRequired, + setPrefix: PropTypes.string.isRequired, + createSet: PropTypes.func.isRequired +} + +export default QuestionSetAddSet diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetAddSetHelp.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetAddSetHelp.js new file mode 100644 index 0000000000..adbd972755 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetAddSetHelp.js @@ -0,0 +1,17 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Html from 'rdmo/core/assets/js/components/Html' + +const QuestionSetAddSetHelp = ({ templates, questionset }) => { + return questionset.is_collection && ( + + ) +} + +QuestionSetAddSetHelp.propTypes = { + templates: PropTypes.object.isRequired, + questionset: PropTypes.object.isRequired +} + +export default QuestionSetAddSetHelp diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetDeleteModal.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetDeleteModal.js new file mode 100644 index 0000000000..78e9e244be --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetDeleteModal.js @@ -0,0 +1,23 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Modal from 'rdmo/core/assets/js/components/Modal' + +const QuestionSetDeleteModal = ({ title, show, onClose, onSubmit }) => { + return ( + +

{gettext('You are about to permanently delete this block.')}

+

{gettext('This action cannot be undone!')}

+
+ ) +} + +QuestionSetDeleteModal.propTypes = { + title: PropTypes.string.isRequired, + show: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +} + +export default QuestionSetDeleteModal diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetHelp.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetHelp.js new file mode 100644 index 0000000000..23e0fb04e2 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetHelp.js @@ -0,0 +1,22 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty } from 'lodash' + +import Html from 'rdmo/core/assets/js/components/Html' + +const QuestionSetHelp = ({ questionset }) => { + const classnames = classNames({ + 'help-text': true + }) + + return !isEmpty(questionset.help) && ( + + ) +} + +QuestionSetHelp.propTypes = { + questionset: PropTypes.object.isRequired +} + +export default QuestionSetHelp diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetHelpTemplate.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetHelpTemplate.js new file mode 100644 index 0000000000..04e688cc72 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetHelpTemplate.js @@ -0,0 +1,14 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Html from 'rdmo/core/assets/js/components/Html' + +const QuestionSetHelpTemplate = ({ templates }) => { + return +} + +QuestionSetHelpTemplate.propTypes = { + templates: PropTypes.object +} + +export default QuestionSetHelpTemplate diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetRemoveSet.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetRemoveSet.js new file mode 100644 index 0000000000..a7364a8909 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetRemoveSet.js @@ -0,0 +1,40 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { capitalize } from 'lodash' + +import useModal from 'rdmo/core/assets/js/hooks/useModal' + +import QuestionSetDeleteModal from './QuestionSetDeleteModal' + +const QuestionAddSet = ({ questionset, set, deleteSet }) => { + + const {show: showDeleteModal, open: openDeleteModal, close: closeDeleteModal} = useModal() + + const handleDeleteSet = () => { + deleteSet(set) + closeDeleteModal() + } + + return questionset.is_collection && ( + <> + + + + + ) +} + +QuestionAddSet.propTypes = { + questionset: PropTypes.object.isRequired, + set: PropTypes.object.isRequired, + deleteSet: PropTypes.func.isRequired +} + +export default QuestionAddSet diff --git a/rdmo/projects/assets/js/interview/components/main/widget/CheckboxInput.js b/rdmo/projects/assets/js/interview/components/main/widget/CheckboxInput.js new file mode 100644 index 0000000000..38ed08ddea --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/CheckboxInput.js @@ -0,0 +1,94 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useDebouncedCallback } from 'use-debounce' +import { isEmpty, isNil } from 'lodash' + +import AdditionalTextInput from './common/AdditionalTextInput' +import AdditionalTextareaInput from './common/AdditionalTextareaInput' +import OptionHelp from './common/OptionHelp' +import OptionText from './common/OptionText' + +const CheckboxInput = ({ question, value, option, disabled, onCreate, onUpdate, onDelete }) => { + + const checked = !isNil(value) + + const handleChange = () => { + if (checked) { + onDelete(value) + } else { + onCreate(option) + } + } + + const handleAdditionalValueChange = useDebouncedCallback((value, option, additionalInput) => { + if (checked) { + if (option.has_provider) { + onUpdate(value, { + text: option.text, + external_id: option.id, + unit: question.unit, + value_type: question.value_type + }) + } else { + onUpdate(value, { + option: option.id, + unit: question.unit, + value_type: question.value_type + }) + } + } else { + onCreate(option, additionalInput) + } + }, 500) + + return ( +
+ +
+ ) +} + +CheckboxInput.propTypes = { + question: PropTypes.object, + value: PropTypes.object, + option: PropTypes.object, + disabled: PropTypes.bool, + onCreate: PropTypes.func.isRequired, + onUpdate: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired +} + +export default CheckboxInput diff --git a/rdmo/projects/assets/js/interview/components/main/widget/CheckboxWidget.js b/rdmo/projects/assets/js/interview/components/main/widget/CheckboxWidget.js new file mode 100644 index 0000000000..e7d8ee71cb --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/CheckboxWidget.js @@ -0,0 +1,90 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { maxBy } from 'lodash' + +import { gatherOptions } from '../../../utils/options' + +import QuestionError from '../question/QuestionError' +import QuestionSuccess from '../question/QuestionSuccess' + +import CheckboxInput from './CheckboxInput' + +const CheckboxWidget = ({ question, values, currentSet, disabled, createValue, updateValue, deleteValue }) => { + + const handleCreateValue = (option, additionalInput) => { + const lastValue = maxBy(values, (v) => v.collection_index) + const collectionIndex = lastValue ? lastValue.collection_index + 1 : 0 + + const value = { + attribute: question.attribute, + set_prefix: currentSet.set_prefix, + set_index: currentSet.set_index, + collection_index: collectionIndex, + set_collection: question.set_collection, + unit: question.unit, + value_type: question.value_type + } + + if (option.has_provider) { + value.external_id = option.id + value.text = option.text + } else { + value.option = option.id + value.text = additionalInput + } + + createValue(value, true) + } + + const success = values.some((value) => value.success) + + return ( +
+
+
+
+
+ { + gatherOptions(question).map((option, optionIndex) => { + const value = values.find((value) => ( + option.has_provider ? (value.external_id === option.id) : (value.option === option.id) + )) + + return ( + + + + + ) + }) + } +
+
+ +
+
+
+
+
+ ) +} + +CheckboxWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default CheckboxWidget diff --git a/rdmo/projects/assets/js/interview/components/main/widget/DateInput.js b/rdmo/projects/assets/js/interview/components/main/widget/DateInput.js new file mode 100644 index 0000000000..20e5f89b69 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/DateInput.js @@ -0,0 +1,81 @@ +import React from 'react' +import PropTypes from 'prop-types' +import DatePicker from 'react-datepicker' +import classNames from 'classnames' +import { enGB, de, it, es, fr } from 'date-fns/locale' + +import lang from 'rdmo/core/assets/js/utils/lang' + +import { isDefaultValue } from '../../../utils/value' + +const DateInput = ({ question, value, disabled, updateValue, buttons }) => { + + const getLocale = () => { + switch (lang) { + case 'de': + return de + case 'it': + return it + case 'es': + return es + case 'fr': + return fr + default: + return enGB + } + } + + const getDateFormat = () => { + switch (lang) { + case 'de': + return 'dd.MM.yyyy' + case 'it': + return 'dd/MM/yyyy' + case 'es': + return 'dd/MM/yyyy' + case 'fr': + return 'dd/MM/yyyy' + default: + return 'dd/MM/yyyy' + } + } + + const handleChange = (date) => { + const text = date.toISOString().slice(0,10) + updateValue(value, { text, unit: question.unit, value_type: question.value_type }) + } + + const classnames = classNames({ + 'form-control': true, + 'date-control': true, + 'default': isDefaultValue(question, value) + }) + + return ( +
+
+ {buttons} + handleChange(date)} + locale={getLocale()} + dateFormat={getDateFormat()} + disabled={disabled} + popperPlacement="bottom-start" + showPopperArrow={false} + /> +
+
+ ) +} + +DateInput.propTypes = { + question: PropTypes.object.isRequired, + value: PropTypes.object.isRequired, + disabled: PropTypes.bool, + updateValue: PropTypes.func.isRequired, + buttons: PropTypes.node.isRequired +} + +export default DateInput diff --git a/rdmo/projects/assets/js/interview/components/main/widget/DateWidget.js b/rdmo/projects/assets/js/interview/components/main/widget/DateWidget.js new file mode 100644 index 0000000000..09010cbc33 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/DateWidget.js @@ -0,0 +1,66 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import QuestionAddValue from '../question/QuestionAddValue' +import QuestionDefault from '../question/QuestionDefault' +import QuestionError from '../question/QuestionError' +import QuestionEraseValue from '../question/QuestionEraseValue' +import QuestionSuccess from '../question/QuestionSuccess' +import QuestionRemoveValue from '../question/QuestionRemoveValue' + +import DateInput from './DateInput' + +const DateWidget = ({ question, values, currentSet, disabled, createValue, updateValue, deleteValue }) => { + return ( +
+ { + values.map((value, valueIndex) => { + return ( +
+ + + + + +
+ } + /> + +
+ ) + }) + } + + + ) +} + +DateWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default DateWidget diff --git a/rdmo/projects/assets/js/interview/components/main/widget/FileInput.js b/rdmo/projects/assets/js/interview/components/main/widget/FileInput.js new file mode 100644 index 0000000000..51fa03202c --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/FileInput.js @@ -0,0 +1,63 @@ +import React, { useCallback } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { useDropzone } from 'react-dropzone' + +const FileInput = ({ question, value, disabled, updateValue, buttons }) => { + const onDrop = useCallback(acceptedFiles => { + if (acceptedFiles.length == 1) { + updateValue(value, { file: acceptedFiles[0], unit: question.unit, value_type: question.value_type }) + } + }, [value.file]) + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, disabled }) + + const classnames = classNames({ + 'dropzone': true, + 'disabled': disabled + }) + + return ( +
+
+ {buttons} +
+ { + value.file_name ? ( +
+ {gettext('Current file: ')} + event.stopPropagation()}> + {value.file_name} + +
+ ) : ( +
{gettext('No file stored.')}
+ ) + } + +
+ +
+ { + isDragActive ? ( + {gettext('Drop the files here ...')} + ) : ( + {gettext('Drag \'n drop some files here, or click to select files.')} + ) + } +
+
+
+
+
+ ) +} + +FileInput.propTypes = { + question: PropTypes.object.isRequired, + value: PropTypes.object.isRequired, + disabled: PropTypes.bool, + updateValue: PropTypes.func.isRequired, + buttons: PropTypes.node.isRequired +} + +export default FileInput diff --git a/rdmo/projects/assets/js/interview/components/main/widget/FileWidget.js b/rdmo/projects/assets/js/interview/components/main/widget/FileWidget.js new file mode 100644 index 0000000000..d30b1a57d0 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/FileWidget.js @@ -0,0 +1,66 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import QuestionAddValue from '../question/QuestionAddValue' +import QuestionDefault from '../question/QuestionDefault' +import QuestionEraseValue from '../question/QuestionEraseValue' +import QuestionSuccess from '../question/QuestionSuccess' +import QuestionError from '../question/QuestionError' +import QuestionRemoveValue from '../question/QuestionRemoveValue' + +import FileInput from './FileInput' + +const FileWidget = ({ question, values, currentSet, disabled, createValue, updateValue, deleteValue }) => { + return ( +
+ { + values.map((value, valueIndex) => { + return ( +
+ + + + + +
+ } + /> + +
+ ) + }) + } + + + ) +} + +FileWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default FileWidget diff --git a/rdmo/projects/assets/js/interview/components/main/widget/RadioInput.js b/rdmo/projects/assets/js/interview/components/main/widget/RadioInput.js new file mode 100644 index 0000000000..026fbd115a --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/RadioInput.js @@ -0,0 +1,122 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { useDebouncedCallback } from 'use-debounce' +import { isEmpty } from 'lodash' + +import { isDefaultValue } from '../../../utils/value' + +import AdditionalTextInput from './common/AdditionalTextInput' +import AdditionalTextareaInput from './common/AdditionalTextareaInput' +import OptionHelp from './common/OptionHelp' +import OptionText from './common/OptionText' + +const RadioInput = ({ question, value, options, disabled, updateValue, buttons }) => { + const handleChange = (option) => { + if (option.has_provider) { + updateValue(value, { + text: option.text, + external_id: option.id, + unit: question.unit, + value_type: question.value_type + }) + } else { + updateValue(value, { + option: option.id, + unit: question.unit, + value_type: question.value_type + }) + } + } + + const handleAdditionalValueChange = useDebouncedCallback((value, option, additionalInput) => { + updateValue(value, { + option: option.id, + text: additionalInput, + unit: question.unit, + value_type: question.value_type + }) + }, 500) + + const classnames = classNames({ + 'radio-control': true, + 'radio': true, + 'default': isDefaultValue(question, value) + }) + + return ( +
+
+ {buttons} +
+ { + isEmpty(options) ? ( +
{gettext('No options are available.')}
+ ) : ( + options.map((option, optionIndex) => ( +
+ +
+ )) + ) + } +
+
+
+ ) +} + +RadioInput.propTypes = { + question: PropTypes.object.isRequired, + value: PropTypes.object.isRequired, + options: PropTypes.array.isRequired, + disabled: PropTypes.bool, + updateValue: PropTypes.func.isRequired, + buttons: PropTypes.node.isRequired +} + +export default RadioInput diff --git a/rdmo/projects/assets/js/interview/components/main/widget/RadioWidget.js b/rdmo/projects/assets/js/interview/components/main/widget/RadioWidget.js new file mode 100644 index 0000000000..f1dd333a52 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/RadioWidget.js @@ -0,0 +1,69 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { gatherOptions } from '../../../utils/options' + +import QuestionAddValue from '../question/QuestionAddValue' +import QuestionDefault from '../question/QuestionDefault' +import QuestionError from '../question/QuestionError' +import QuestionSuccess from '../question/QuestionSuccess' +import QuestionEraseValue from '../question/QuestionEraseValue' +import QuestionRemoveValue from '../question/QuestionRemoveValue' + +import RadioInput from './RadioInput' + +const RadioWidget = ({ question, values, currentSet, disabled, createValue, updateValue, deleteValue }) => { + return ( +
+ { + values.map((value, valueIndex) => { + return ( +
+ + + + + +
+ } + /> + +
+ ) + }) + } + + + ) +} + +RadioWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default RadioWidget diff --git a/rdmo/projects/assets/js/interview/components/main/widget/RangeInput.js b/rdmo/projects/assets/js/interview/components/main/widget/RangeInput.js new file mode 100644 index 0000000000..7f28d9f28b --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/RangeInput.js @@ -0,0 +1,53 @@ +import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import { useDebouncedCallback } from 'use-debounce' +import classNames from 'classnames' +import { isEmpty } from 'lodash' + +import { isDefaultValue } from '../../../utils/value' + +import Unit from './common/Unit' + +const RangeInput = ({ question, value, disabled, updateValue, buttons }) => { + const [inputValue, setInputValue] = useState('') + useEffect(() => {setInputValue(value.text)}, [value.text]) + + const handleChange = useDebouncedCallback((value, text) => { + updateValue(value, { text, unit: question.unit, value_type: question.value_type }) + }, 500) + + const classnames = classNames({ + 'interview-input': true, + 'range': true, + 'default': isDefaultValue(question, value) + }) + + return ( +
+ { + setInputValue(event.target.value) + handleChange(value, event.target.value) + }} + /> + + {buttons} +
+ ) +} + +RangeInput.propTypes = { + question: PropTypes.object.isRequired, + value: PropTypes.object.isRequired, + disabled: PropTypes.bool, + updateValue: PropTypes.func.isRequired, + buttons: PropTypes.node.isRequired +} + +export default RangeInput diff --git a/rdmo/projects/assets/js/interview/components/main/widget/RangeWidget.js b/rdmo/projects/assets/js/interview/components/main/widget/RangeWidget.js new file mode 100644 index 0000000000..692f66d5a4 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/RangeWidget.js @@ -0,0 +1,79 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { initRange } from '../../../utils/value' + +import QuestionAddValue from '../question/QuestionAddValue' +import QuestionDefault from '../question/QuestionDefault' +import QuestionError from '../question/QuestionError' +import QuestionSuccess from '../question/QuestionSuccess' +import QuestionEraseValue from '../question/QuestionEraseValue' +import QuestionRemoveValue from '../question/QuestionRemoveValue' + +import RangeInput from './RangeInput' + +const RangeWidget = ({ question, values, currentSet, disabled, createValue, updateValue, deleteValue }) => { + + const handleCreateValue = (value) => { + initRange(question, value) + createValue(value) + } + + const handleEraseValue = (value, attrs) => { + initRange(question, attrs) + updateValue(value, attrs) + } + + return ( +
+ { + values.map((value, valueIndex) => { + return ( +
+ + + + + +
+ } + /> + +
+ ) + }) + } + + + ) +} + +RangeWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default RangeWidget diff --git a/rdmo/projects/assets/js/interview/components/main/widget/SelectInput.js b/rdmo/projects/assets/js/interview/components/main/widget/SelectInput.js new file mode 100644 index 0000000000..a0d694957e --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/SelectInput.js @@ -0,0 +1,142 @@ +import React, { useState } from 'react' +import Select from 'react-select' +import AsyncSelect from 'react-select/async' +import CreatableSelect from 'react-select/creatable' +import CreatableAsyncSelect from 'react-select/async-creatable' + +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty, isNil } from 'lodash' +import { useDebouncedCallback } from 'use-debounce' +// import { convert } from 'html-to-text' + +import ProjectApi from '../../../api/ProjectApi' +import { projectId } from '../../../utils/meta' +import { isDefaultValue } from '../../../utils/value' +import { getValueOption } from '../../../utils/options' + +import OptionHelp from './common/OptionHelp' +import OptionText from './common/OptionText' + + +const SelectInput = ({ question, value, options, disabled, creatable, updateValue, buttons }) => { + + const [inputValue, setInputValue] = useState('') + // const [isOpen, setIsOpen] = useState(false) + + const handleChange = (option) => { + if (isNil(option)) { + // close the select input when the value is reset + // setIsOpen(false) + setInputValue('') + + updateValue(value, {}) + } else if (option.__isNew__ === true) { + updateValue(value, { + text: option.value, + unit: question.unit, + value_type: question.value_type + }) + } else { + if (option.has_provider) { + updateValue(value, { + external_id: option.id, + text: option.text, + unit: question.unit, + value_type: question.value_type + }) + } else { + updateValue(value, { + option: option.id, + unit: question.unit, + value_type: question.value_type + }) + } + } + } + + const handleLoadOptions = useDebouncedCallback((searchText, callback) => { + // Updating "options" through the redux store is buggy, so we use AsyncSelect + // and use a asyncrounous callback to update the options in the select field. + // Note that the "options" array in the component remains []. + const search = searchText || value.text + if (isEmpty(search)) { + callback([]) + } else { + Promise.all(question.optionsets.map((optionset) => { + return ProjectApi.fetchOptions(projectId, optionset.id, search) + })).then((results) => { + const options = results.reduce((selectOptions, options) => { + return [...selectOptions, ...options.map(option => ({...option, has_provider: true}))] + }, []) + + callback(options) + }) + } + }, 500) + + const classnames = classNames({ + 'react-select': true, + 'default': isDefaultValue(question, value) + }) + + const valueOption = getValueOption(options, value) + + const isAsync = question.optionsets.some((optionset) => optionset.has_search) + + const selectProps = { + classNamePrefix: 'react-select', + className: classnames, + backspaceRemovesValue: false, + isDisabled: disabled, + placeholder: gettext('Select ...'), + noOptionsMessage: () => gettext('No options found'), + loadingMessage: () => gettext('Loading ...'), + options: options, + value: valueOption, + inputValue: inputValue, + onInputChange: setInputValue, + onChange: handleChange, + getOptionValue: (option) => option.id, + getOptionLabel: (option) => option.text, + formatOptionLabel: (option) => ( + + + + + ) + } + + return ( +
+ { + creatable ? ( + isAsync ? ( + + ) : ( + + ) + ) : ( + isAsync ? ( + + ) : ( + { + setInputValue(event.target.value) + handleChange(value, event.target.value) + }} + /> +
+ + + ) +} + +TextInput.propTypes = { + question: PropTypes.object.isRequired, + value: PropTypes.object.isRequired, + disabled: PropTypes.bool, + updateValue: PropTypes.func.isRequired, + buttons: PropTypes.node.isRequired +} + +export default TextInput diff --git a/rdmo/projects/assets/js/interview/components/main/widget/TextWidget.js b/rdmo/projects/assets/js/interview/components/main/widget/TextWidget.js new file mode 100644 index 0000000000..7eb55f8032 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/TextWidget.js @@ -0,0 +1,64 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import QuestionAddValue from '../question/QuestionAddValue' +import QuestionDefault from '../question/QuestionDefault' +import QuestionError from '../question/QuestionError' +import QuestionRemoveValue from '../question/QuestionRemoveValue' +import QuestionSuccess from '../question/QuestionSuccess' + +import TextInput from './TextInput' + +const TextWidget = ({ question, values, currentSet, disabled, createValue, updateValue, deleteValue }) => { + return ( +
+ { + values.map((value, valueIndex) => { + return ( +
+ + + + +
+ } + /> + +
+ ) + }) + } + + + ) +} + +TextWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default TextWidget diff --git a/rdmo/projects/assets/js/interview/components/main/widget/TextareaInput.js b/rdmo/projects/assets/js/interview/components/main/widget/TextareaInput.js new file mode 100644 index 0000000000..bc8cf49437 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/TextareaInput.js @@ -0,0 +1,57 @@ +import React, { useState, useEffect, useRef } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { useDebouncedCallback } from 'use-debounce' + +import { isDefaultValue } from '../../../utils/value' + +import useFocusEffect from '../../../hooks/useFocusEffect' + +import Unit from './common/Unit' + +const TextareaInput = ({ question, value, disabled, updateValue, buttons }) => { + const ref = useRef(null) + const [inputValue, setInputValue] = useState('') + + useEffect(() => {setInputValue(value.text)}, [value.text]) + useFocusEffect(ref, value.focus) + + const handleChange = useDebouncedCallback((value, text) => { + updateValue(value, { text, unit: question.unit, value_type: question.value_type }) + }, 500) + + const classnames = classNames({ + 'form-control': true, + 'default': isDefaultValue(question, value) + }) + + return ( +
+
+ {buttons} +