diff --git a/.gitignore b/.gitignore index 2af3bf59da..7db0e82c47 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,13 @@ dist rdmo/management/static +rdmo/core/static/core/js/base.js +rdmo/core/static/core/fonts +rdmo/core/static/core/css/base.css + +rdmo/projects/static/projects/js/interview.js rdmo/projects/static/projects/js/projects.js rdmo/projects/static/projects/fonts -rdmo/projects/static/projects/css/projects.css +rdmo/projects/static/projects/css/*.css screenshots 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 d2e79d2b81..a644339c7f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "scripts": { "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", @@ -19,6 +20,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", @@ -27,6 +29,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", @@ -36,7 +39,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/static/core/fonts/DroidSans-Bold.ttf b/rdmo/core/assets/fonts/DroidSans-Bold.ttf similarity index 100% rename from rdmo/core/static/core/fonts/DroidSans-Bold.ttf rename to rdmo/core/assets/fonts/DroidSans-Bold.ttf diff --git a/rdmo/core/static/core/fonts/DroidSans.ttf b/rdmo/core/assets/fonts/DroidSans.ttf similarity index 100% rename from rdmo/core/static/core/fonts/DroidSans.ttf rename to rdmo/core/assets/fonts/DroidSans.ttf diff --git a/rdmo/core/static/core/fonts/DroidSansMono.ttf b/rdmo/core/assets/fonts/DroidSansMono.ttf similarity index 100% rename from rdmo/core/static/core/fonts/DroidSansMono.ttf rename to rdmo/core/assets/fonts/DroidSansMono.ttf diff --git a/rdmo/core/static/core/fonts/DroidSerif-Bold.ttf b/rdmo/core/assets/fonts/DroidSerif-Bold.ttf similarity index 100% rename from rdmo/core/static/core/fonts/DroidSerif-Bold.ttf rename to rdmo/core/assets/fonts/DroidSerif-Bold.ttf diff --git a/rdmo/core/static/core/fonts/DroidSerif-BoldItalic.ttf b/rdmo/core/assets/fonts/DroidSerif-BoldItalic.ttf similarity index 100% rename from rdmo/core/static/core/fonts/DroidSerif-BoldItalic.ttf rename to rdmo/core/assets/fonts/DroidSerif-BoldItalic.ttf diff --git a/rdmo/core/static/core/fonts/DroidSerif-Italic.ttf b/rdmo/core/assets/fonts/DroidSerif-Italic.ttf similarity index 100% rename from rdmo/core/static/core/fonts/DroidSerif-Italic.ttf rename to rdmo/core/assets/fonts/DroidSerif-Italic.ttf diff --git a/rdmo/core/static/core/fonts/DroidSerif.ttf b/rdmo/core/assets/fonts/DroidSerif.ttf similarity index 100% rename from rdmo/core/static/core/fonts/DroidSerif.ttf rename to rdmo/core/assets/fonts/DroidSerif.ttf diff --git a/rdmo/core/assets/img/favicon.png b/rdmo/core/assets/img/favicon.png new file mode 100644 index 0000000000..042bcf2bbb Binary files /dev/null and b/rdmo/core/assets/img/favicon.png differ diff --git a/rdmo/core/assets/img/rdmo-logo.svg b/rdmo/core/assets/img/rdmo-logo.svg new file mode 100644 index 0000000000..93fc2eae24 --- /dev/null +++ b/rdmo/core/assets/img/rdmo-logo.svg @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + RDMO + + diff --git a/rdmo/core/assets/js/actions/actionTypes.js b/rdmo/core/assets/js/actions/actionTypes.js new file mode 100644 index 0000000000..1f97c54f3d --- /dev/null +++ b/rdmo/core/assets/js/actions/actionTypes.js @@ -0,0 +1,17 @@ +export const UPDATE_CONFIG = 'UPDATE_CONFIG' +export const DELETE_CONFIG = 'DELETE_CONFIG' + +export const ADD_TO_PENDING = 'ADD_TO_PENDING' +export const REMOVE_FROM_PENDING = 'REMOVE_FROM_PENDING' + +export const FETCH_SETTINGS_ERROR = 'FETCH_SETTINGS_ERROR' +export const FETCH_SETTINGS_INIT = 'FETCH_SETTINGS_INIT' +export const FETCH_SETTINGS_SUCCESS = 'FETCH_SETTINGS_SUCCESS' + +export const FETCH_TEMPLATES_ERROR = 'FETCH_TEMPLATES_ERROR' +export const FETCH_TEMPLATES_INIT = 'FETCH_TEMPLATES_INIT' +export const FETCH_TEMPLATES_SUCCESS = 'FETCH_TEMPLATES_SUCCESS' + +export const FETCH_CURRENT_USER_ERROR = 'FETCH_CURRENT_USER_ERROR' +export const FETCH_CURRENT_USER_INIT = 'FETCH_CURRENT_USER_INIT' +export const FETCH_CURRENT_USER_SUCCESS = 'FETCH_CURRENT_USER_SUCCESS' diff --git a/rdmo/core/assets/js/actions/configActions.js b/rdmo/core/assets/js/actions/configActions.js new file mode 100644 index 0000000000..0aaebe47b9 --- /dev/null +++ b/rdmo/core/assets/js/actions/configActions.js @@ -0,0 +1,9 @@ +import { UPDATE_CONFIG, DELETE_CONFIG } from './actionTypes' + +export function updateConfig(path, value, ls = false) { + return {type: UPDATE_CONFIG, path, value, ls} +} + +export function deleteConfig(path, ls = false) { + return {type: DELETE_CONFIG, path, ls} +} diff --git a/rdmo/core/assets/js/actions/pendingActions.js b/rdmo/core/assets/js/actions/pendingActions.js new file mode 100644 index 0000000000..2ee4aaf514 --- /dev/null +++ b/rdmo/core/assets/js/actions/pendingActions.js @@ -0,0 +1,9 @@ +import { ADD_TO_PENDING, REMOVE_FROM_PENDING } from './actionTypes' + +export function addToPending(item) { + return {type: ADD_TO_PENDING, item} +} + +export function removeFromPending(item) { + return {type: REMOVE_FROM_PENDING, item} +} diff --git a/rdmo/core/assets/js/actions/settingsActions.js b/rdmo/core/assets/js/actions/settingsActions.js new file mode 100644 index 0000000000..d95f6be36a --- /dev/null +++ b/rdmo/core/assets/js/actions/settingsActions.js @@ -0,0 +1,25 @@ +import CoreApi from '../api/CoreApi' + +import { FETCH_SETTINGS_ERROR, FETCH_SETTINGS_INIT, FETCH_SETTINGS_SUCCESS } from './actionTypes' + +export function fetchSettings() { + return function(dispatch) { + dispatch(fetchSettingsInit()) + + return CoreApi.fetchSettings() + .then((settings) => dispatch(fetchSettingsSuccess(settings))) + .catch((errors) => dispatch(fetchSettingsError(errors))) + } +} + +export function fetchSettingsInit() { + return {type: FETCH_SETTINGS_INIT} +} + +export function fetchSettingsSuccess(settings) { + return {type: FETCH_SETTINGS_SUCCESS, settings} +} + +export function fetchSettingsError(errors) { + return {type: FETCH_SETTINGS_ERROR, errors} +} diff --git a/rdmo/core/assets/js/actions/templateActions.js b/rdmo/core/assets/js/actions/templateActions.js new file mode 100644 index 0000000000..90e598e342 --- /dev/null +++ b/rdmo/core/assets/js/actions/templateActions.js @@ -0,0 +1,25 @@ +import CoreApi from '../api/CoreApi' + +import { FETCH_TEMPLATES_ERROR, FETCH_TEMPLATES_INIT, FETCH_TEMPLATES_SUCCESS } from './actionTypes' + +export function fetchTemplates() { + return function(dispatch) { + dispatch(fetchTemplatesInit()) + + return CoreApi.fetchTemplates() + .then((templates) => dispatch(fetchTemplatesSuccess(templates))) + .catch((errors) => dispatch(fetchTemplatesError(errors))) + } +} + +export function fetchTemplatesInit() { + return {type: FETCH_TEMPLATES_INIT} +} + +export function fetchTemplatesSuccess(templates) { + return {type: FETCH_TEMPLATES_SUCCESS, templates} +} + +export function fetchTemplatesError(errors) { + return {type: FETCH_TEMPLATES_ERROR, errors} +} diff --git a/rdmo/core/assets/js/actions/userActions.js b/rdmo/core/assets/js/actions/userActions.js new file mode 100644 index 0000000000..2c2922428d --- /dev/null +++ b/rdmo/core/assets/js/actions/userActions.js @@ -0,0 +1,25 @@ +import AccountsApi from '../api/AccountsApi' + +import { FETCH_CURRENT_USER_ERROR, FETCH_CURRENT_USER_INIT, FETCH_CURRENT_USER_SUCCESS } from './actionTypes' + +export function fetchCurrentUser() { + return function(dispatch) { + dispatch(fetchCurrentUserInit()) + + return AccountsApi.fetchCurrentUser(true) + .then(currentUser => dispatch(fetchCurrentUserSuccess({ currentUser }))) + .catch(error => dispatch(fetchCurrentUserError(error))) + } +} + +export function fetchCurrentUserInit() { + return {type: FETCH_CURRENT_USER_INIT} +} + +export function fetchCurrentUserSuccess(currentUser) { + return {type: FETCH_CURRENT_USER_SUCCESS, currentUser} +} + +export function fetchCurrentUserError(error) { + return {type: FETCH_CURRENT_USER_ERROR, error} +} diff --git a/rdmo/projects/assets/js/api/AccountsApi.js b/rdmo/core/assets/js/api/AccountsApi.js similarity index 100% rename from rdmo/projects/assets/js/api/AccountsApi.js rename to rdmo/core/assets/js/api/AccountsApi.js diff --git a/rdmo/core/assets/js/api/BaseApi.js b/rdmo/core/assets/js/api/BaseApi.js index c2d81e9473..ae0281bb69 100644 --- a/rdmo/core/assets/js/api/BaseApi.js +++ b/rdmo/core/assets/js/api/BaseApi.js @@ -1,7 +1,7 @@ import Cookies from 'js-cookie' import isUndefined from 'lodash/isUndefined' -import baseUrl from '../utils/baseUrl' +import { baseUrl } from '../utils/meta' function ApiError(statusText, status) { this.status = status @@ -52,6 +52,28 @@ class BaseApi { }) } + static postFormData(url, formData) { + return fetch(baseUrl + url, { + method: 'POST', + headers: { + 'X-CSRFToken': Cookies.get('csrftoken') + }, + body: formData + }).catch(error => { + throw new ApiError(error.message) + }).then(response => { + if (response.ok) { + return response.json() + } else if (response.status == 400) { + return response.json().then(errors => { + throw new ValidationError(errors) + }) + } else { + throw new ApiError(response.statusText, response.status) + } + }) + } + static put(url, data) { return fetch(baseUrl + url, { method: 'PUT', diff --git a/rdmo/core/assets/js/api/CoreApi.js b/rdmo/core/assets/js/api/CoreApi.js index ef77eb4701..d97cfc2983 100644 --- a/rdmo/core/assets/js/api/CoreApi.js +++ b/rdmo/core/assets/js/api/CoreApi.js @@ -14,6 +14,10 @@ class CoreApi extends BaseApi { return this.get('/api/v1/core/groups/') } + static fetchTemplates() { + return this.get('/api/v1/core/templates/') + } + } export default CoreApi diff --git a/rdmo/core/assets/js/base.js b/rdmo/core/assets/js/base.js new file mode 100644 index 0000000000..3bab0caba5 --- /dev/null +++ b/rdmo/core/assets/js/base.js @@ -0,0 +1 @@ +import 'bootstrap-sass' diff --git a/rdmo/core/assets/js/components/Html.js b/rdmo/core/assets/js/components/Html.js new file mode 100644 index 0000000000..acd13bbd23 --- /dev/null +++ b/rdmo/core/assets/js/components/Html.js @@ -0,0 +1,16 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { isEmpty } from 'lodash' + +const Html = ({ html = '' }) => { + return !isEmpty(html) && ( +
+ ) +} + +Html.propTypes = { + className: PropTypes.string, + html: PropTypes.string +} + +export default Html diff --git a/rdmo/core/assets/js/components/Modal.js b/rdmo/core/assets/js/components/Modal.js index a3f2cd70b8..2ef42a543b 100644 --- a/rdmo/core/assets/js/components/Modal.js +++ b/rdmo/core/assets/js/components/Modal.js @@ -2,24 +2,29 @@ import React from 'react' import PropTypes from 'prop-types' import { Modal as BootstrapModal } from 'react-bootstrap' -const Modal = ({ bsSize, buttonLabel, buttonProps, title, show, onClose, onSave, children }) => { +const Modal = ({ title, show, modalProps, submitLabel, submitProps, onClose, onSubmit, children }) => { return ( - +

{title}

- - { children } - + { + children && ( + + { children } + + ) + } - { onSave ? - - : null + { + onSubmit && ( + + ) }
@@ -27,14 +32,14 @@ const Modal = ({ bsSize, buttonLabel, buttonProps, title, show, onClose, onSave, } Modal.propTypes = { - bsSize: PropTypes.oneOf(['lg', 'large', 'sm', 'small']), - buttonLabel: PropTypes.string, - buttonProps: PropTypes.object, - children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired, - onClose: PropTypes.func.isRequired, - onSave: PropTypes.func, - show: PropTypes.bool.isRequired, title: PropTypes.string.isRequired, + show: PropTypes.bool.isRequired, + modalProps: PropTypes.object, + submitLabel: PropTypes.string, + submitProps: PropTypes.object, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func, + children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired, } export default Modal diff --git a/rdmo/core/assets/js/containers/Pending.js b/rdmo/core/assets/js/containers/Pending.js new file mode 100644 index 0000000000..07609f8a79 --- /dev/null +++ b/rdmo/core/assets/js/containers/Pending.js @@ -0,0 +1,24 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { isEmpty } from 'lodash' + +const Pending = ({ pending }) => { + return ( + !isEmpty(pending.items) && ( + + ) + ) +} + +Pending.propTypes = { + pending: PropTypes.object.isRequired, +} + +function mapStateToProps(state) { + return { + pending: state.pending, + } +} + +export default connect(mapStateToProps)(Pending) diff --git a/rdmo/core/assets/js/hooks/useLsState.js b/rdmo/core/assets/js/hooks/useLsState.js new file mode 100644 index 0000000000..de81de45b4 --- /dev/null +++ b/rdmo/core/assets/js/hooks/useLsState.js @@ -0,0 +1,41 @@ +import { useState } from 'react' +import { isEmpty, isPlainObject, omit as lodashOmit } from 'lodash' + +import { checkStoreId } from '../utils/store' + +const useLsState = (path, initialValue, omit = []) => { + checkStoreId() + + const getLs = (path) => { + const data = JSON.parse(localStorage.getItem(path)) + return isPlainObject(data) ? lodashOmit(data, omit) : data + } + + const setLs = (path, value) => { + const data = isPlainObject(value) ? lodashOmit(value, omit) : value + localStorage.setItem(path, JSON.stringify(data)) + } + + // get the value from the local storage + const lsValue = getLs(path) + + // setup the state with the value from the local storage or the provided initialValue + const [value, setValue] = useState(isEmpty(lsValue) ? initialValue : lsValue) + + return [ + value, + (value) => { + setLs(path, value) + setValue(value) + }, + () => { + if (isPlainObject(initialValue)) { + setValue({ ...initialValue, ...getLs(path) }) + } else { + setValue(initialValue) + } + } + ] +} + +export default useLsState diff --git a/rdmo/core/assets/js/reducers/configReducer.js b/rdmo/core/assets/js/reducers/configReducer.js new file mode 100644 index 0000000000..9001d4e8c4 --- /dev/null +++ b/rdmo/core/assets/js/reducers/configReducer.js @@ -0,0 +1,22 @@ +import { updateConfig, deleteConfig, setConfigInLocalStorage, deleteConfigInLocalStorage } from '../utils/config' + +import { DELETE_CONFIG, UPDATE_CONFIG } from '../actions/actionTypes' + +const initialState = {} + +export default function configReducer(state = initialState, action) { + switch(action.type) { + case UPDATE_CONFIG: + if (action.ls) { + setConfigInLocalStorage(state.prefix, action.path, action.value) + } + return updateConfig(state, action.path, action.value) + case DELETE_CONFIG: + if (action.ls) { + deleteConfigInLocalStorage(state.prefix, action.path) + } + return deleteConfig(state, action.path) + default: + return state + } +} diff --git a/rdmo/core/assets/js/reducers/pendingReducer.js b/rdmo/core/assets/js/reducers/pendingReducer.js new file mode 100644 index 0000000000..7f5b9de1cc --- /dev/null +++ b/rdmo/core/assets/js/reducers/pendingReducer.js @@ -0,0 +1,16 @@ +import { ADD_TO_PENDING, REMOVE_FROM_PENDING } from '../actions/actionTypes' + +const initialState = { + items: [] +} + +export default function pendingReducer(state = initialState, action) { + switch(action.type) { + case ADD_TO_PENDING: + return { ...state, items: [...state.items, action.item] } + case REMOVE_FROM_PENDING: + return { ...state, items: state.items.filter((item) => (item != action.item)) } + default: + return state + } +} diff --git a/rdmo/core/assets/js/reducers/settingsReducer.js b/rdmo/core/assets/js/reducers/settingsReducer.js new file mode 100644 index 0000000000..379f1829de --- /dev/null +++ b/rdmo/core/assets/js/reducers/settingsReducer.js @@ -0,0 +1,14 @@ +import { FETCH_SETTINGS_ERROR, FETCH_SETTINGS_SUCCESS } from '../actions/actionTypes' + +const initialState = {} + +export default function settingsReducer(state = initialState, action) { + switch(action.type) { + case FETCH_SETTINGS_SUCCESS: + return { ...state, ...action.settings } + case FETCH_SETTINGS_ERROR: + return { ...state, errors: action.errors } + default: + return state + } +} diff --git a/rdmo/core/assets/js/reducers/templateReducer.js b/rdmo/core/assets/js/reducers/templateReducer.js new file mode 100644 index 0000000000..b7897f2eb8 --- /dev/null +++ b/rdmo/core/assets/js/reducers/templateReducer.js @@ -0,0 +1,14 @@ +import { FETCH_TEMPLATES_ERROR, FETCH_TEMPLATES_SUCCESS } from '../actions/actionTypes' + +const initialState = {} + +export default function templateReducer(state = initialState, action) { + switch(action.type) { + case FETCH_TEMPLATES_SUCCESS: + return { ...state, ...action.templates } + case FETCH_TEMPLATES_ERROR: + return { ...state, errors: action.errors } + default: + return state + } +} diff --git a/rdmo/core/assets/js/reducers/userReducer.js b/rdmo/core/assets/js/reducers/userReducer.js new file mode 100644 index 0000000000..8c76285f56 --- /dev/null +++ b/rdmo/core/assets/js/reducers/userReducer.js @@ -0,0 +1,18 @@ +import { FETCH_CURRENT_USER_ERROR, FETCH_CURRENT_USER_INIT, FETCH_CURRENT_USER_SUCCESS } from '../actions/actionTypes' + +const initialState = { + currentUser: {}, +} + +export default function userReducer(state = initialState, action) { + switch(action.type) { + case FETCH_CURRENT_USER_INIT: + return {...state, ...action.currentUser} + case FETCH_CURRENT_USER_SUCCESS: + return {...state, ...action.currentUser} + case FETCH_CURRENT_USER_ERROR: + return {...state, errors: action.error.errors} + default: + return state + } +} diff --git a/rdmo/core/assets/js/utils/baseUrl.js b/rdmo/core/assets/js/utils/baseUrl.js deleted file mode 100644 index 22a17df960..0000000000 --- a/rdmo/core/assets/js/utils/baseUrl.js +++ /dev/null @@ -1,2 +0,0 @@ -// take the baseurl from the of the django template -export default document.querySelector('meta[name="baseurl"]').content.replace(/\/+$/, '') diff --git a/rdmo/core/assets/js/utils/config.js b/rdmo/core/assets/js/utils/config.js new file mode 100644 index 0000000000..22b1be6460 --- /dev/null +++ b/rdmo/core/assets/js/utils/config.js @@ -0,0 +1,58 @@ +import { set, unset, toNumber, isNaN } from 'lodash' + +const updateConfig = (config, path, value) => { + const newConfig = {...config} + set(newConfig, path, value) + return newConfig +} + +const deleteConfig = (config, path) => { + const newConfig = {...config} + unset(newConfig, path) + return newConfig +} + +const getConfigFromLocalStorage = (prefix) => { + const ls = {...localStorage} + + return Object.entries(ls) + .filter(([lsPath,]) => lsPath.startsWith(prefix)) + .map(([lsPath, lsValue]) => { + if (lsPath.startsWith(prefix)) { + const path = lsPath.replace(`${prefix}.`, '') + + // check if it is literal 'true' or 'false' + if (lsValue === 'true') { + return [path, true] + } else if (lsValue === 'false') { + return [path, false] + } + + // check if the value is number or a string + const numberValue = toNumber(lsValue) + if (isNaN(numberValue)) { + return [path, lsValue] + } else { + return [path, numberValue] + } + } else { + return null + } + }) +} + +const setConfigInLocalStorage = (prefix, path, value) => { + localStorage.setItem(`${prefix}.${path}`, value) +} + +const deleteConfigInLocalStorage = (prefix, path) => { + localStorage.removeItem(`${prefix}.${path}`) +} + +export { + updateConfig, + deleteConfig, + getConfigFromLocalStorage, + setConfigInLocalStorage, + deleteConfigInLocalStorage +} diff --git a/rdmo/core/assets/js/utils/index.js b/rdmo/core/assets/js/utils/index.js index c2d32da182..30d03229a7 100644 --- a/rdmo/core/assets/js/utils/index.js +++ b/rdmo/core/assets/js/utils/index.js @@ -1,5 +1,2 @@ export * from './api' -export { default as baseUrl } from './baseUrl' -export { default as language } from './language' -export { default as siteId } from './siteId' -export { default as staticUrl } from './staticUrl' +export { baseUrl, language, siteId, staticUrl } from './meta' 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/assets/js/utils/language.js b/rdmo/core/assets/js/utils/language.js deleted file mode 100644 index 58dc8a369e..0000000000 --- a/rdmo/core/assets/js/utils/language.js +++ /dev/null @@ -1,2 +0,0 @@ -// take the language from the of the django template -export default document.querySelector('meta[name="language"]').content diff --git a/rdmo/core/assets/js/utils/meta.js b/rdmo/core/assets/js/utils/meta.js new file mode 100644 index 0000000000..d8186e6c2f --- /dev/null +++ b/rdmo/core/assets/js/utils/meta.js @@ -0,0 +1,9 @@ +// take information from the of the django template + +export const baseUrl = document.querySelector('meta[name="baseurl"]').content.replace(/\/+$/, '') + +export const staticUrl = document.querySelector('meta[name="staticurl"]').content.replace(/\/+$/, '') + +export const siteId = Number(document.querySelector('meta[name="site_id"]').content) + +export const language = document.querySelector('meta[name="language"]').content diff --git a/rdmo/core/assets/js/utils/siteId.js b/rdmo/core/assets/js/utils/siteId.js deleted file mode 100644 index 7b413b672e..0000000000 --- a/rdmo/core/assets/js/utils/siteId.js +++ /dev/null @@ -1,2 +0,0 @@ -// take the site_id from the of the django template -export default Number(document.querySelector('meta[name="site_id"]').content) diff --git a/rdmo/core/assets/js/utils/staticUrl.js b/rdmo/core/assets/js/utils/staticUrl.js deleted file mode 100644 index 0a1323cb10..0000000000 --- a/rdmo/core/assets/js/utils/staticUrl.js +++ /dev/null @@ -1,2 +0,0 @@ -// take the staticurl from the of the django template -export default document.querySelector('meta[name="staticurl"]').content.replace(/\/+$/, '') diff --git a/rdmo/core/assets/js/utils/store.js b/rdmo/core/assets/js/utils/store.js new file mode 100644 index 0000000000..d30203fd59 --- /dev/null +++ b/rdmo/core/assets/js/utils/store.js @@ -0,0 +1,14 @@ +import Cookies from 'js-cookie' +import isEmpty from 'lodash/isEmpty' + +const checkStoreId = () => { + const currentStoreId = Cookies.get('storeid') + const localStoreId = localStorage.getItem('rdmo.storeid') + + if (isEmpty(localStoreId) || localStoreId !== currentStoreId) { + localStorage.clear() + localStorage.setItem('rdmo.storeid', currentStoreId) + } +} + +export { checkStoreId } diff --git a/rdmo/core/assets/scss/base.scss b/rdmo/core/assets/scss/base.scss new file mode 100644 index 0000000000..dadff686a5 --- /dev/null +++ b/rdmo/core/assets/scss/base.scss @@ -0,0 +1,15 @@ +$icon-font-path: "bootstrap-sass/assets/fonts/bootstrap/"; +@import '~bootstrap-sass'; +@import '~font-awesome/css/font-awesome.css'; + +@import 'react-datepicker/dist/react-datepicker.css'; + +@import 'variables'; +@import 'style'; + +@import 'codemirror'; +@import 'fonts'; +@import 'footer'; +@import 'header'; +@import 'swagger'; +@import 'utils'; diff --git a/rdmo/core/assets/scss/codemirror.scss b/rdmo/core/assets/scss/codemirror.scss new file mode 100644 index 0000000000..be9f2c0bef --- /dev/null +++ b/rdmo/core/assets/scss/codemirror.scss @@ -0,0 +1,9 @@ +.CodeMirror { + font-family: DroidSans-Mono, mono; +} + +formgroup .CodeMirror { + border-radius: 4px; + border: 1px solid #ccc; + color: #555; +} diff --git a/rdmo/core/assets/scss/fonts.scss b/rdmo/core/assets/scss/fonts.scss new file mode 100644 index 0000000000..4d1f217320 --- /dev/null +++ b/rdmo/core/assets/scss/fonts.scss @@ -0,0 +1,44 @@ +@font-face { + font-family: "DroidSans"; + src: url('../fonts/DroidSans.ttf'); +} +@font-face { + font-family: "DroidSans"; + src: url('../fonts/DroidSans-Bold.ttf'); + font-weight: bold; +} +@font-face { + font-family: "DroidSans-Mono"; + src: url('../fonts/DroidSansMono.ttf'); +} +@font-face { + font-family: "DroidSerif"; + src: url('../fonts/DroidSerif.ttf'); +} +@font-face { + font-family: "DroidSerif"; + src: url('../fonts/DroidSerif-Bold.ttf'); + font-weight: bold; +} +@font-face { + font-family: "DroidSerif"; + src: url('../fonts/DroidSerif-Italic.ttf'); + font-style: italic; +} +@font-face { + font-family: "DroidSerif"; + src: url('../fonts/DroidSerif-BoldItalic.ttf'); + font-style: italic; + font-weight: bold; +} + +body { + font-family: DroidSans, sans; +} +h1, h2, h3, h4, h5, h6 { + font-family: DroidSerif, serif; +} + +a.fa { + text-decoration: none !important; +} diff --git a/rdmo/core/assets/scss/footer.scss b/rdmo/core/assets/scss/footer.scss new file mode 100644 index 0000000000..7b4838c053 --- /dev/null +++ b/rdmo/core/assets/scss/footer.scss @@ -0,0 +1,55 @@ +$footer-height: 280px; +$footer-height-md: 600px; +$footer-height-sm: 260px; + +/* footer layout */ + +.content { + min-height: 100%; + margin-bottom: -$footer-height; + padding-bottom: $footer-height; +} +footer { + height: $footer-height; +} +@media (max-width: $screen-sm-max) { + .content { + margin-bottom: -$footer-height-md; + padding-bottom: $footer-height-md; + } + footer { + height: $footer-height-md; + } +} +@media (max-width: $screen-xs-max) { + .content { + margin-bottom: -$footer-height-md; + padding-bottom: $footer-height-md; + } + footer { + height: $footer-height-md; + } +} + +/* footer style */ + +footer { + color: $footer-color; + background-color: $footer-background-color; + padding-top: 20px; + + a, + a:visited, + a:hover { + color: $footer-link-color; + } + h4 { + color: $footer-link-color; + } + p { + text-align: left; + } + img { + display: block; + } +} diff --git a/rdmo/core/assets/scss/header.scss b/rdmo/core/assets/scss/header.scss new file mode 100644 index 0000000000..769b65c0df --- /dev/null +++ b/rdmo/core/assets/scss/header.scss @@ -0,0 +1,89 @@ +$header-height: 400px; +$header-height-md: 300px; + +header { + position: relative; + + height: $header-height; + background-color: black; + + .header-image { + position: absolute; + left: 0; + right: 0; + + opacity: 0; + -webkit-transition: $image-transition; + -moz-transition: $image-transition; + -ms-transition: $image-transition; + -o-transition: $image-transition; + transition: $image-transition; + + &.visible { + opacity: 1; + } + img { + display: block; + width: 100%; + height: $header-height; + } + p { + position: absolute; + bottom: 0; + right: 0; + z-index: 10; + + padding-right: 5px; + margin-bottom: 5px; + font-size: 10px; + color: $footer-link-color; + + } + a, + a:visited, + a:hover { + color: $footer-link-color; + } + } + .header-text { + position: relative; + padding-top: 100px; + + h1 { + font-size: 60px; + color: white; + } + p { + font-size: 30px; + color: white; + } + } +} +@media (max-width: $screen-md-max) { + header { + height: $header-height-md; + } + header .header-image img { + height: $header-height-md; + } + header .header-text { + padding-top: 50px; + } +} +@media (max-width: $screen-xs-max) { + header { + background-color: inherit; + height: auto; + } + header .header-text { + padding-top: 0; + } + header .header-text h1 { + font-size: 40px; + color: $headline-color; + } + header .header-text p { + font-size: 20px; + color: $variant-color; + } +} diff --git a/rdmo/core/assets/scss/style.scss b/rdmo/core/assets/scss/style.scss new file mode 100644 index 0000000000..f64ab6347f --- /dev/null +++ b/rdmo/core/assets/scss/style.scss @@ -0,0 +1,502 @@ +html, body { + height: 100%; + background-color: $background-color; +} + +h1, h2, h3, h4 { + color: $headline-color; + background-color: $headline-background-color; + line-height: 40px; +} +h5, h6 { + color: $headline-color; + background-color: $headline-background-color; + font-size: medium; + line-height: 20px; +} +h1 { + font-size: 28px; +} +h2 { + font-size: 24px; +} +.sidebar h2, +.modal h2 { + font-size: 20px; +} +h3 { + font-size: 16px; +} +h4 { + font-size: 14px; +} +form { + margin-bottom: 20px; +} +.extend { + width: 100%; +} + +a { + color: $link-color; + + &:visited { + color: $link-color-visited; + } + &:hover { + color: $link-color-hover; + } + &:focus { + color: $link-color-focus; + } + + &.btn { + color: white; + + &:visited, + &:hover, + &:focus { + color: white; + } + } + &.text-warning { + &:visited, + &:hover, + &:focus { + color: #8a6d3b; + } + } + &.text-danger { + &:visited, + &:hover, + &:focus { + color: #a94442; + } + } + + &.disabled { + cursor: not-allowed; + } +} + +code { + word-wrap: break-word; + + &.code-questions { + color: rgb(16, 31, 112); + background-color: rgba(16, 31, 112, 0.1); + } + &.code-options { + color: rgb(255, 100, 0); + background-color: rgba(255, 100, 0, 0.1); + } + &.code-options-provider { + color: white; + background-color: rgba(255, 100, 0, 0.8); + } + &.code-conditions { + color: rgb(128, 0, 128); + background-color: rgba(128, 0, 128, 0.1); + } + &.code-tasks { + color: rgb(128, 0, 0); + background-color: rgba(128, 0, 0, 0.1); + } + &.code-views { + color: rgb(0, 128, 0); + background-color: rgba(0, 128, 0, 0.1); + } + &.code-order { + color: rgb(96, 96, 96); + background-color: rgba(96, 96, 96, 0.1); + } + &.code-import { + color: black; + background-color: rgba(96, 96, 96, 0.1); + } +} + +table { + p { + margin-bottom: 5px; + } + p:last-child { + margin-bottom: 0; + } +} + +.table-break-word { + td { + word-break: break-all; + } +} + +details { + margin-bottom: 10px; +} + +summary { + display: list-item; + cursor: pointer; + margin-bottom: 5px; +} + +metadata { + display: none; +} + +/* navbar */ + +.navbar-default { + background-color: $navigation-background-color; + border-bottom: none; + + .navbar-brand, + .navbar-nav > li > a, + .navbar-nav > li > a:focus { + color: $navigation-color; + background-color: transparent; + } + .navbar-brand:hover, + .navbar-nav > li > a:hover, + .navbar-nav > .open > a, + .navbar-nav > .open > a:focus, + .navbar-nav > .open > a:hover { + color: $navigation-hover-color; + background-color: $navigation-hover-background-color; + } + + .dropdown li.divider:first-child { + display: none; + } +} + +/* content */ + +.content { + padding-top: 50px; /* same height as the navbar */ +} +.sidebar { + /* make the sidebar sticky */ + position: -webkit-sticky; + position: sticky; + top: 0; +} +.page, .sidebar { + height: 100%; + margin-top: 10px; + margin-bottom: 60px; +} +.page h2:nth-child(2) { + margin-top: 0; +} +.sidebar h2:first-child, +.sidebar .import-buttons { + margin-top: 70px; +} + +/* questions overview */ + +.section-panel { + +} + +.subsection-panel { + margin-left: 40px; +} + +.group-panel { + margin-left: 80px; + + table th:first-child, + table td:first-child { + padding-left: 15px; + } + + table th:last-child, + table td:last-child { + padding-right: 15px; + } +} + +/* angular forms */ + +.input-collection { + margin-bottom: 15px; +} + +/* forms */ + +.form-label { + margin-bottom: 5px; + font-weight: 700; +} + +form .yesno label { + margin-right: 10px; +} + +.row { + .checkbox, + .radio { + margin-top: 10px; + margin-bottom: 10px; + } + + @media (min-width: $screen-xs-max) { + .checkbox-padding .checkbox, + .radio-padding .radio { + margin-top: 32px; + margin-bottom: 11px; + } + } +} + +.input-xs { + height: 24px; + padding: 5px 10px; + font-size: 11px; + line-height: 1; + border-radius: 2px; +} + +.help-block.info { + margin-top: 0; +} + +.sidebar-form { + display: flex; + gap: 5px; +} + +.upload-form { + .upload-form-field { + position: relative; + + cursor: pointer; + border-radius: 4px; + + flex-grow: 1; + overflow: hidden; + + p, + input { + height: 34px; + margin: 0px; + } + + p { + text-align: left; + cursor: pointer; + + color: $link-color; + border: 1px solid silver; + border-radius: 4px; + + width: calc(100% - 1px); + padding: 6px 14px; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + input { + position: absolute; + z-index: 1; + padding: 0; + opacity: 0; + } + + &:hover { + background-color: #e6e6e6; + } + } +} + +/* modals */ + +.modal-body { + > div:last-child, + > p:last-child, + formgroup:last-child .form-group { + margin-bottom: 0; + } + + .copy-block { + margin-bottom: 20px; + } + + .help-block { + font-size: small; + word-break: break-word; + } + + .nav.nav-tabs { + margin-bottom: 20px; + } +} + +/* options */ + +.options-dropdown { + display: inline-block; + + > a { + cursor: pointer; + } +} + +/* panels */ + +.panel-default { + min-height: 5px; +} + +.panel-body { + padding-top: 10px; + padding-bottom: 10px; +} + +.panel li > p:last-child { + margin-bottom: 0; +} + +/* lists */ + +ul.list-arrow li { + margin-left: 20px; + + &.active { + margin-left: 0; + } + + &.active a:before { + float: left; + width: 20px; + text-align: right; + content: '\2192\0000a0'; /* right-arrow followed by a space */ + } +} + +/* misc */ +.form-errors { + margin-bottom: 20px; +} +li > a.control-label > i { + display: none; +} +li.has-error > a.control-label > i, +li.has-warning > a.control-label > i { + display: inline; +} +.email-form label, +.connections-form label { + display: block; + margin: 0; + line-height: 40px; + border-bottom: 1px solid $modal-border-color; +} +.email-form label:first-child, +.connections-form label:first-child { + border-top: 1px solid $modal-border-color; +} +.email-form label input, +.connections-form label input { + margin-left: 5px; + margin-right: 5px; +} +.email-form .email-form-buttons, +.connections-form .connections-form-buttons { + margin-top: 10px; +} +.socialaccount_providers { + margin: 0; + padding: 0; + height: 42px; +} +.socialaccount_providers li { + float: left; + margin: 0 5px 10px 5px; + list-style: none; +} +.socialaccount_providers li.socialaccount_provider_break { + float: none; + margin-left: 0; + margin-right: 0; +} +.socialaccount_provider_name { + line-height: 29px; + font-weight: bold; +} +.logout-form { + margin: 0; +} +.logout-form .btn-link { + padding: 3px 20px; + color: $navigation-dropdown-color; + display: block; + width: 100%; + text-align: left; + border: none; + clear: both; + font-weight: 400; + line-height: 1.42857143; + white-space: nowrap; +} +.logout-form .btn-link:hover { + color: $navigation-dropdown-hover-color; + background-color: $navigation-dropdown-hover-background-color; + text-decoration: none; +} +.logout-form .btn-link:focus { + color: $navigation-dropdown-hover-color; + background-color: $navigation-dropdown-hover-background-color; + text-decoration: none; + outline: none; +} +.rdmo-logo { + width: 240px; + margin-top: 40px; +} + +// adjust background "hover" color in select2 to $link-color +.select2-results__option--highlighted{ + background-color: $link-color !important, +} + +.cc-myself { + .checkbox { + margin: 0; + } +} + +.ng-binding { + :last-child { + margin-bottom: 0; + } +} + +.inline_image { + max-width: 100%; +} + +[data-toggle="tooltip"] { + cursor: help; + text-decoration: underline; + text-decoration-style: dotted; +} + +.more, +.show-less { + display: none; +} +.show-more, +.show-less { + color: $link-color; + cursor: pointer; +} + +.has-error .react-select__control { + border-color: #a94442; +} diff --git a/rdmo/core/assets/scss/swagger.scss b/rdmo/core/assets/scss/swagger.scss new file mode 100644 index 0000000000..0a0ecc6cac --- /dev/null +++ b/rdmo/core/assets/scss/swagger.scss @@ -0,0 +1,28 @@ +.topbar { + background-color: $headline-color !important; +} + +.swagger-ui .info { + margin: 30px; +} + +.swagger-ui .btn.authorize { + border-color: $footer-background-color; + color: $text-color; +} + +.swagger-ui .btn.authorize svg { + fill: $footer-background-color; +} + +.swagger-ui .btn.authorize { + color: $footer-background-color !important; +} + +.topbar img { + filter: hue-rotate(180deg) +} + +.download-url-wrapper .download-url-button { + background-color: $headline-color !important; +} diff --git a/rdmo/core/assets/scss/utils.scss b/rdmo/core/assets/scss/utils.scss new file mode 100644 index 0000000000..0e45f92470 --- /dev/null +++ b/rdmo/core/assets/scss/utils.scss @@ -0,0 +1,92 @@ +.flip { + transform: rotate(180deg) scaleX(-1); +} + +.w-100 { + width: 100%; +} +.mt-0 { + margin-top: 0; +} +.mt-5 { + margin-top: 5px; +} +.mt-10 { + margin-top: 10px; +} +.mt-20 { + margin-top: 20px; +} +.mr-0 { + margin-right: 0; +} +.mr-5 { + margin-right: 5px; +} +.mr-10 { + margin-right: 10px; +} +.mr-20 { + margin-right: 20px; +} +.mb-0 { + margin-bottom: 0; +} +.mb-5 { + margin-bottom: 5px; +} +.mb-10 { + margin-bottom: 10px; +} +.mb-20 { + margin-bottom: 20px; +} +.ml-0 { + margin-left: 0; +} +.ml-5 { + margin-left: 5px; +} +.ml-10 { + margin-left: 10px; +} +.ml-20 { + margin-left: 20px; +} + +.pt-0 { + padding-top: 0; +} +.pt-10 { + padding-top: 10px; +} +.pt-20 { + padding-top: 20px; +} +.pr-0 { + padding-right: 0; +} +.pr-10 { + padding-right: 10px; +} +.pr-20 { + padding-right: 20px; +} +.pb-0 { + padding-bottom: 0; +} +.pb-10 { + padding-bottom: 10px; +} +.pb-20 { + padding-bottom: 20px; +} +.pl-0 { + padding-left: 0; +} +.pl-10 { + padding-left: 10px; +} +.pl-20 { + padding-left: 20px; +} diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py index 422e7886fc..7cf6b783b9 100644 --- a/rdmo/core/settings.py +++ b/rdmo/core/settings.py @@ -197,6 +197,7 @@ 'MULTISITE', 'GROUPS', 'EXPORT_FORMATS', + 'PROJECT_VISIBILITY', 'PROJECT_ISSUES', 'PROJECT_VIEWS', 'PROJECT_EXPORTS', @@ -218,6 +219,22 @@ 'PROJECT_TABLE_PAGE_SIZE' ] +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' @@ -276,6 +293,8 @@ PROJECT_TABLE_PAGE_SIZE = 20 +PROJECT_VISIBILITY = True + PROJECT_ISSUES = True PROJECT_ISSUE_PROVIDERS = [] @@ -318,6 +337,8 @@ OPTIONSET_PROVIDERS = [] +PROJECT_VALUES_SEARCH_LIMIT = 10 + PROJECT_VALUES_VALIDATION = False PROJECT_VALUES_VALIDATION_URL = True 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/core/tests/test_viewset_templates.py b/rdmo/core/tests/test_viewset_templates.py new file mode 100644 index 0000000000..ea90ffc989 --- /dev/null +++ b/rdmo/core/tests/test_viewset_templates.py @@ -0,0 +1,32 @@ +import pytest + +from django.urls import reverse + +users = ( + ('owner', 'owner'), + ('manager', 'manager'), + ('author', 'author'), + ('guest', 'guest'), + ('api', 'api'), + ('user', 'user'), + ('anonymous', None), +) + +status_map = { + 'list': { + 'owner': 200, 'manager': 200, 'author': 200, 'guest': 200, 'api': 200, 'user': 200, 'anonymous': 401 + } +} + +urlnames = { + 'list': 'template-list', +} + + +@pytest.mark.parametrize('username,password', users) +def test_list(db, client, username, password): + client.login(username=username, password=password) + + url = reverse(urlnames['list']) + response = client.get(url) + assert response.status_code == status_map['list'][username], response.json() diff --git a/rdmo/core/urls/v1.py b/rdmo/core/urls/v1.py index 442d612de5..6e7139a9a4 100644 --- a/rdmo/core/urls/v1.py +++ b/rdmo/core/urls/v1.py @@ -2,12 +2,13 @@ from rest_framework import routers -from ..viewsets import GroupViewSet, SettingsViewSet, SitesViewSet +from ..viewsets import GroupViewSet, SettingsViewSet, SitesViewSet, TemplatesViewSet router = routers.DefaultRouter() router.register(r'settings', SettingsViewSet, basename='setting') router.register(r'sites', SitesViewSet, basename='site') router.register(r'groups', GroupViewSet, basename='group') +router.register(r'templates', TemplatesViewSet, basename='template') urlpatterns = [ path('accounts/', include('rdmo.accounts.urls.v1')), diff --git a/rdmo/core/viewsets.py b/rdmo/core/viewsets.py index eaa97a2072..ea4e41af1a 100644 --- a/rdmo/core/viewsets.py +++ b/rdmo/core/viewsets.py @@ -1,6 +1,9 @@ +from pathlib import Path + from django.conf import settings from django.contrib.auth.models import Group from django.contrib.sites.models import Site +from django.template.loader import get_template from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated @@ -31,3 +34,14 @@ class GroupViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = (HasModelPermission, ) queryset = Group.objects.all() serializer_class = GroupSerializer + + +class TemplatesViewSet(viewsets.GenericViewSet): + + permission_classes = (IsAuthenticated, ) + + def list(self, request, *args, **kwargs): + return Response({ + Path(template_path).stem: get_template(template_path).render(request=request).strip() + for template_path in settings.TEMPLATES_API + }) 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/management/assets/js/reducers/configReducer.js b/rdmo/management/assets/js/reducers/configReducer.js index 31029c17f0..1476203c16 100644 --- a/rdmo/management/assets/js/reducers/configReducer.js +++ b/rdmo/management/assets/js/reducers/configReducer.js @@ -1,6 +1,6 @@ import set from 'lodash/set' -import baseUrl from 'rdmo/core/assets/js/utils/baseUrl' +import { baseUrl } from 'rdmo/core/assets/js/utils/meta' const initialState = { baseUrl: baseUrl + '/management/', 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 1c82a6a5e8..1ea65071e3 100644 --- a/rdmo/projects/admin.py +++ b/rdmo/projects/admin.py @@ -105,7 +105,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..b42995cdbf --- /dev/null +++ b/rdmo/projects/assets/js/interview/actions/actionTypes.js @@ -0,0 +1,56 @@ +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' + +export const COPY_SET_INIT = 'COPY_SET_INIT' +export const COPY_SET_SUCCESS = 'COPY_SET_SUCCESS' +export const COPY_SET_ERROR = 'COPY_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..09c5c71dcc --- /dev/null +++ b/rdmo/projects/assets/js/interview/actions/interviewActions.js @@ -0,0 +1,648 @@ +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, compareValues, isEmptyValue } 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, + COPY_SET_INIT, + COPY_SET_SUCCESS, + COPY_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.findIndex((v) => compareValues(v, value)) + const valueFile = value.file + const valueSuccess = value.success + + 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(addToPending(pendingId)) + dispatch(storeValueInit(valueIndex)) + + return ValueApi.storeValue(projectId, value, { page: page.id, question: question && question.id }) + .then((value) => { + 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 copyValue(value) { + return (dispatch, getState) => { + const sets = getState().interview.sets + const values = getState().interview.values + + sets.filter((set) => ( + (set.set_prefix == value.set_prefix) && + (set.set_index != value.set_index) + )).forEach((set) => { + const sibling = values.find((v) => ( + (v.attribute == value.attribute) && + (v.set_prefix == set.set_prefix) && + (v.set_index == set.set_index) && + (v.collection_index == value.collection_index) + )) + + if (isNil(sibling)) { + dispatch(storeValue(ValueFactory.create({ ...value, set_index: set.set_index }))) + } else if (isEmptyValue(sibling)) { + dispatch(storeValue(ValueFactory.update(sibling, value))) + } + }) + } +} + +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)) + + 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) + + if (isNil(value.id)) { + return dispatch(deleteValueSuccess(value)) + } else { + return ValueApi.deleteValue(projectId, value, { page: page.id, question: question && question.id }) + .then(() => { + + 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 a callback function to be called immediately or after saving the value + const createSetCallback = (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 createSetCallback() + } else { + return dispatch(storeValue(value)).then(() => { + const storedValue = getState().interview.values.find((v) => compareValues(v, value)) + if (!isNil(storedValue)) { + createSetCallback(storedValue) + } + }) + } + } +} + +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} +} + +export function copySet(currentSet, copySetValue, attrs) { + const pendingId = isNil(currentSet) ? 'copySet' : `copySet/${currentSet.set_prefix}/${currentSet.set_index}` + + return (dispatch, getState) => { + dispatch(addToPending(pendingId)) + dispatch(copySetInit()) + + // create a new set (create) or use the current one (import) + const set = isNil(attrs.id) ? SetFactory.create(attrs) : currentSet + + // create a value for the text if the page has an attribute (create) or use the current one (import) + const value = isNil(attrs.attribute) ? null : ( + isNil(attrs.id) ? ValueFactory.create(attrs) : attrs + ) + + // create a callback function to be called immediately or after saving the value + const copySetCallback = (setValues) => { + dispatch(activateSet(set)) + + const state = getState().interview + + const page = state.page + const values = [ + ...state.values.filter(v => !setValues.some(sv => compareValues(v, sv))), // remove updated values + ...setValues + ] + const sets = gatherSets(values) + + initSets(sets, page) + initValues(sets, values, page) + + return dispatch({type: COPY_SET_SUCCESS, values, sets}) + } + + let promise + if (isNil(value)) { + // gather all values for the currentSet and it's descendants + const currentValues = getDescendants(getState().interview.values, currentSet) + + // store each value in currentSet with the new set_index + promise = Promise.all( + currentValues.filter((currentValue) => !isEmptyValue(currentValue)).map((currentValue) => { + const value = {...currentValue} + const setPrefixLength = set.set_prefix.split('|').length + + if (value.set_prefix == set.set_prefix) { + value.set_index = set.set_index + } else { + value.set_prefix = value.set_prefix.split('|').reduce((acc, cur, idx) => { + return [...acc, (idx == setPrefixLength - 1) ? set.set_index : cur] + }, []).join('|') + } + + delete value.id + return ValueApi.storeValue(projectId, value) + }) + ) + } else { + promise = ValueApi.copySet(projectId, value, copySetValue) + } + + return promise.then((values) => { + dispatch(removeFromPending(pendingId)) + dispatch(copySetCallback(values)) + }).catch((errors) => { + dispatch(removeFromPending(pendingId)) + dispatch(copySetError(errors)) + }) + } +} + +export function copySetInit() { + return {type: COPY_SET_INIT} +} + +export function copySetSuccess(values, sets) { + return {type: COPY_SET_SUCCESS, values, sets} +} + +export function copySetError(errors) { + return {type: COPY_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..bdd9efb77f --- /dev/null +++ b/rdmo/projects/assets/js/interview/api/ProjectApi.js @@ -0,0 +1,51 @@ +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 fetchProjects(params) { + return this.get(`/api/v1/projects/projects/?${encodeParams(params)}`) + } + + 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..e299f93b75 --- /dev/null +++ b/rdmo/projects/assets/js/interview/api/ValueApi.js @@ -0,0 +1,48 @@ +import BaseApi from 'rdmo/core/assets/js/api/BaseApi' +import { encodeParams } from 'rdmo/core/assets/js/utils/api' +import { isUndefined } from 'lodash' + +class ValueApi extends BaseApi { + + static fetchValues(projectId, params) { + return this.get(`/api/v1/projects/projects/${projectId}/values/?${encodeParams(params)}`) + } + + static searchValues(params) { + return this.get(`/api/v1/projects/values/search/?${encodeParams(params)}`) + } + + static storeValue(projectId, value, params) { + if (isUndefined(value.id)) { + return this.post(`/api/v1/projects/projects/${projectId}/values/?${encodeParams(params)}`, value) + } else { + return this.put(`/api/v1/projects/projects/${projectId}/values/${value.id}/?${encodeParams(params)}`, 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, params) { + if (!isUndefined(value.id)) { + return this.delete(`/api/v1/projects/projects/${projectId}/values/${value.id}/?${encodeParams(params)}`) + } + } + + static copySet(projectId, setValue, copySetValue) { + return this.post(`/api/v1/projects/projects/${projectId}/values/set/`, { + ...setValue, copy_set_value: copySetValue.id + }) + } + + static deleteSet(projectId, setValue) { + return this.delete(`/api/v1/projects/projects/${projectId}/values/${setValue.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/Search.js b/rdmo/projects/assets/js/interview/components/main/Search.js new file mode 100644 index 0000000000..143962aeb6 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/Search.js @@ -0,0 +1,136 @@ +import React from 'react' +import PropTypes from 'prop-types' +import AsyncSelect from 'react-select/async' +import { useDebouncedCallback } from 'use-debounce' +import { isEmpty, isNil, pick } from 'lodash' + +import ProjectApi from '../../api/ProjectApi' +import ValueApi from '../../api/ValueApi' + +const Search = ({ attribute, values, setValues, collection = false }) => { + // create a key for the first AsyncSelect, to reset the loaded values when project or snapshot changes + const key = (values.project ? values.project.id : '') + (values.snapshot ? '-all' : '') + + const handleLoadValues = useDebouncedCallback((search, callback) => { + ValueApi.searchValues({ + attribute, + search, + project: values.project ? values.project.id : '', + snapshot: values.snapshot ? 'all' : '', + collection + }).then(response => { + if (collection) { + // if the search component is used from QuestionReuseValues/CheckboxWidget + // the list of values from the server needs to be reduced to show only one entry + // for each set_prefix/set_index combination + callback(response.reduce((collections, value) => { + const { project_label, snapshot_label, value_label, set_prefix, set_index } = value + + // look if a value for the same set_prefix/set_index already exists in values_list + const collection = isNil(collections) ? null : collections.find(v => ( + (v.set_prefix == set_prefix) && (v.set_index == set_index) + )) + if (isNil(collection)) { + // append the value + return [...collections, { + project_label, snapshot_label, value_label, set_prefix, set_index, values: [value] + }] + } else { + // update the value_title and the values array of the existing value + collection.value_label += '; ' + value.value_label + collection.values.push(value) + return collections + } + }, [])) + } else { + callback(response) + } + }) + }, 500) + + const handleLoadProjects = useDebouncedCallback((search, callback) => { + ProjectApi.fetchProjects({ search }) + .then(response => callback(response.results.map(project => pick(project, 'id', 'title')))) + }, 500) + + return <> + gettext( + 'No answers match your search.' + )} + loadingMessage={() => gettext('Loading ...')} + options={[]} + value={values.value} + onChange={(value) => setValues({ ...values, value })} + getOptionValue={(value) => value} + getOptionLabel={(value) => value.value_label} + formatOptionLabel={(value) => ( +
+ {gettext('Project')} {value.project_label} + { + value.snapshot && <> + + {gettext('Snapshot')} {value.snapshot_label} + + } + + {value.value_label} +
+ )} + loadOptions={handleLoadValues} + defaultOptions + isClearable + backspaceRemovesValue={true} + /> + + gettext( + 'No projects matching your search.' + )} + loadingMessage={() => gettext('Loading ...')} + options={[]} + value={values.project} + onChange={(project) => setValues({ + ...values, + value: (isEmpty(project) || project == values.project) ? values.value : '', // reset value + project: project + })} + getOptionValue={(project) => project} + getOptionLabel={(project) => project.title} + loadOptions={handleLoadProjects} + defaultOptions + isClearable + backspaceRemovesValue={true} + /> + +
+ +
+ +} + +Search.propTypes = { + attribute: PropTypes.number.isRequired, + values: PropTypes.object.isRequired, + setValues: PropTypes.func.isRequired, + collection: PropTypes.bool +} + +export default Search 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..a250256dbd --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/Page.js @@ -0,0 +1,126 @@ +import React from 'react' +import PropTypes from 'prop-types' +import get from 'lodash/get' +import { isNil, minBy } 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, copyValue, + activateSet, createSet, updateSet, deleteSet, copySet }) => { + + const currentSetPrefix = '' + let currentSetIndex = page.is_collection ? get(config, 'page.currentSetIndex', 0) : 0 + let currentSet = sets.find((set) => (set.set_prefix == currentSetPrefix && set.set_index == currentSetIndex)) + + // sanity check + if (isNil(currentSet)) { + currentSetIndex = get(minBy(sets, 'set_index'), 'set_index', 0) + currentSet = sets.find((set) => (set.set_prefix == currentSetPrefix && set.set_index == currentSetIndex)) + } + + 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} + copySet={copySet} + /> +
+ { + 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} + copySet={copySet} + createValue={createValue} + updateValue={updateValue} + deleteValue={deleteValue} + copyValue={copyValue} + /> + ) + } else { + return ( + ( + value.attribute == element.attribute && + value.set_prefix == currentSetPrefix && + value.set_index == currentSetIndex + ))} + siblings={values.filter((value) => ( + 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} + copyValue={copyValue} + /> + ) + } + }) + ) + } +
+ + +
+ ) +} + +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, + copyValue: PropTypes.func.isRequired, + activateSet: PropTypes.func.isRequired, + createSet: PropTypes.func.isRequired, + updateSet: PropTypes.func.isRequired, + deleteSet: PropTypes.func.isRequired, + copySet: 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..ff22d84b46 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/PageHead.js @@ -0,0 +1,195 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { capitalize, isEmpty, 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' +import PageHeadReuseModal from './PageHeadReuseModal' + +const PageHead = ({ templates, page, sets, values, currentSet, + activateSet, createSet, updateSet, deleteSet, copySet }) => { + + const currentSetValue = isNil(currentSet) ? null : ( + values.find((value) => ( + value.set_prefix == currentSet.set_prefix && value.set_index == currentSet.set_index + )) + ) + + const createModal = useModal() + const updateModal = useModal() + const copyModal = useModal() + const importModal = useModal() + const deleteModal = useModal() + + const handleActivate = (event, set) => { + event.preventDefault() + if (set.set_index != currentSet.set_index) { + activateSet(set) + } + } + + const handleOpenCreateModal = (event) => { + event.preventDefault() + createModal.open() + } + + const handleCreate = (text, copySetValue) => { + if (isEmpty(copySetValue)) { + createSet({ + attribute: page.attribute, + set_index: last(sets) ? last(sets).set_index + 1 : 0, + set_collection: page.is_collection, + text + }) + } else { + copySet(currentSet, copySetValue, { + attribute: page.attribute, + set_index: last(sets) ? last(sets).set_index + 1 : 0, + set_collection: page.is_collection, + text + }) + } + createModal.close() + } + + const handleUpdate = (text) => { + updateSet(currentSetValue, { text }) + updateModal.close() + } + + const handleDelete = () => { + deleteSet(currentSet, currentSetValue) + deleteModal.close() + } + + const handleCopy = (text) => { + copySet(currentSet, currentSetValue, { + attribute: page.attribute, + set_index: last(sets) ? last(sets).set_index + 1 : 0, + set_collection: page.is_collection, + text + }) + copyModal.close() + } + + const handleImport = (copySetValue) => { + copySet(currentSet, copySetValue, currentSetValue) + importModal.close() + } + + return page.is_collection && ( +
+ + + { + currentSet ? ( + <> + +
+ { + page.attribute && ( +
+ + ) : ( + + ) + } + + + + { + currentSetValue && ( + + ) + } + { + 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, + copySet: 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..f39813f03f --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/PageHeadDeleteModal.js @@ -0,0 +1,25 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Modal from 'rdmo/core/assets/js/components/Modal' + +const PageHeadDeleteModal = ({ 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..f77420583e --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/PageHeadFormModal.js @@ -0,0 +1,114 @@ +import React, { useState, useRef, useEffect } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty, isNil } from 'lodash' + +import useLsState from 'rdmo/core/assets/js/hooks/useLsState' +import useFocusEffect from '../../../hooks/useFocusEffect' + +import Modal from 'rdmo/core/assets/js/components/Modal' +import Search from '../Search' + +const PageHeadFormModal = ({ title, submitLabel, submitColor, show, attribute, reuse, initial, onClose, onSubmit }) => { + + const ref = useRef(null) + + const initialValues = { + text: initial || '', + value: '' + } + + const [values, setValues, initValues] = useLsState('rdmo.interview.reuse', initialValues, ['text', 'value']) + const [errors, setErrors] = useState([]) + + const handleSubmit = () => { + if (isEmpty(values.text)) { + setErrors({ text: true }) + } else { + onSubmit(values.text, values.value) + } + } + + // init the form values + useEffect(() => { + if (show && !isNil(attribute)) { + initValues() + } + }, [show]) + + // remove the hasError flag if an inputValue is entered + useEffect(() => { + if (!isEmpty(values.text)) { + setErrors({ text: false }) + } + }, [values.text]) + + // focus when the modal is shown + useFocusEffect(ref, show) + + return ( + + { + isNil(attribute) ? ( +
+ {gettext('You can add a new tab using the create button.')} +
+ ) : ( + <> +
+ + setValues({ ...values, text: event.target.value })} + onKeyPress={(event) => { + if (event.code === 'Enter') { + handleSubmit() + } + }} + /> + +

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

+
+ { + reuse && ( +
+ + + + +

+ {gettext('You can populate this tab with answers from a similar tab in any ' + + 'project you are allowed to access.')} +

+
+ ) + } + + ) + } +
+ ) +} + +PageHeadFormModal.propTypes = { + title: PropTypes.string.isRequired, + submitLabel: PropTypes.string.isRequired, + submitColor: PropTypes.string.isRequired, + show: PropTypes.bool.isRequired, + attribute: PropTypes.number, + reuse: PropTypes.bool, + initial: PropTypes.object, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired +} + +export default PageHeadFormModal diff --git a/rdmo/projects/assets/js/interview/components/main/page/PageHeadReuseModal.js b/rdmo/projects/assets/js/interview/components/main/page/PageHeadReuseModal.js new file mode 100644 index 0000000000..61039a667d --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/page/PageHeadReuseModal.js @@ -0,0 +1,71 @@ +import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty } from 'lodash' + +import useLsState from 'rdmo/core/assets/js/hooks/useLsState' + +import Modal from 'rdmo/core/assets/js/components/Modal' + +import Search from '../Search' + +const PageHeadReuseModal = ({ show, attribute, onClose, onSubmit }) => { + + const initialValues = { + value: '' + } + + const [values, setValues, initValues] = useLsState('rdmo.interview.reuse', initialValues, ['value']) + const [errors, setErrors] = useState([]) + + const handleSubmit = () => { + if (isEmpty(values.value)) { + setErrors({ value: true }) + } else { + onSubmit(values.value) + } + } + + useEffect(() => { + if (show) { + initValues() + } + }, [show]) + + useEffect(() => { + if (!isEmpty(values.value)) { + // remove the hasError flag if an inputValue is entered + setErrors({ value: false }) + } + }, [values, values.value]) + + return ( + +
+ + + + +

+ {gettext('You can populate this tab with answers from a similar tab in any ' + + 'project you have access to. This only affects questions that ' + + 'don\'t already have an answer.')} +

+
+
+ ) +} + +PageHeadReuseModal.propTypes = { + title: PropTypes.string.isRequired, + show: PropTypes.bool.isRequired, + attribute: PropTypes.number, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired +} + +export default PageHeadReuseModal 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..617cfc945e --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/Question.js @@ -0,0 +1,55 @@ +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, siblings, disabled, isManager, + currentSet, createValue, updateValue, deleteValue, copyValue }) => { + return checkQuestion(question, currentSet) && ( +
+ + + + + + + + +
+ ) +} + +Question.propTypes = { + templates: PropTypes.object.isRequired, + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + siblings: PropTypes.array, + disabled: PropTypes.bool.isRequired, + isManager: PropTypes.bool.isRequired, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired, + copyValue: 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..bf2ee56604 --- /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/QuestionCopyValue.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionCopyValue.js new file mode 100644 index 0000000000..f943386117 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionCopyValue.js @@ -0,0 +1,27 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { isEmptyValue } from '../../../utils/value' + +const QuestionCopyValue = ({ question, value, siblings, copyValue }) => { + return ( + question.set_collection && + !question.is_collection && + !isEmptyValue(value) && + siblings.some((value) => isEmptyValue(value)) && ( + + ) + ) +} + +QuestionCopyValue.propTypes = { + question: PropTypes.object.isRequired, + value: PropTypes.object.isRequired, + siblings: PropTypes.object.isRequired, + copyValue: PropTypes.func.isRequired +} + +export default QuestionCopyValue diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionCopyValues.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionCopyValues.js new file mode 100644 index 0000000000..8a08b0d016 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionCopyValues.js @@ -0,0 +1,38 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { isEmptyValue } from '../../../utils/value' + +const QuestionCopyValues = ({ question, values, siblings, copyValue }) => { + const handleCopyValues = () => { + values.forEach((value) => copyValue(value)) + } + + const button = question.widget_type == 'checkbox' ? ( + + ) : ( + + ) + + return ( + question.is_collection && + question.set_collection && + values.some((v) => !isEmptyValue(v)) && + siblings.some((value) => isEmptyValue(value)) && + button + ) +} + +QuestionCopyValues.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + siblings: PropTypes.array, + copyValue: PropTypes.func.isRequired +} + +export default QuestionCopyValues 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/QuestionReuseValue.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionReuseValue.js new file mode 100644 index 0000000000..2722b4289b --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionReuseValue.js @@ -0,0 +1,77 @@ +import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty } from 'lodash' + +import useLsState from 'rdmo/core/assets/js/hooks/useLsState' + +import Modal from 'rdmo/core/assets/js/components/Modal' + +import Search from '../Search' + +const QuestionReuseValue = ({ value, updateValue }) => { + + const initialFormValues = { + value: '' + } + + const [show, setShow] = useState(false) + const [ + formValues, setFormValues, initFormValues + ] = useLsState('rdmo.interview.reuse', initialFormValues, ['value']) + const [formErrors, setFormErrors] = useState([]) + + const handleSubmit = () => { + if (isEmpty(formValues.value)) { + setFormErrors({ value: true }) + } else { + const { text, option, external_id } = formValues.value + updateValue(value, { text, option, external_id }) + setShow(false) + } + } + + // init the form values + useEffect(() => { + if (show) { + initFormValues() + } + }, [show]) + + // remove the hasError flag if an inputValue is entered + useEffect(() => { + if (!isEmpty(formValues.value)) { + setFormErrors({ value: false }) + } + }, [formValues, formValues.value]) + + return <> + + + setShow(false)} onSubmit={handleSubmit}> +
+ + + + +

+ {gettext('You can reuse an answer from a similar question in any ' + + 'project you have access to.')} +

+
+
+ +} + +QuestionReuseValue.propTypes = { + value: PropTypes.object.isRequired, + updateValue: PropTypes.func.isRequired +} + +export default QuestionReuseValue diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionReuseValues.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionReuseValues.js new file mode 100644 index 0000000000..2722c3d29f --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionReuseValues.js @@ -0,0 +1,110 @@ +import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty, isNil } from 'lodash' + +import useLsState from 'rdmo/core/assets/js/hooks/useLsState' + +import Modal from 'rdmo/core/assets/js/components/Modal' + +import Search from '../Search' + +const QuestionReuseValues = ({ question, values, createValues, updateValue, deleteValue }) => { + + const initialFormValues = { + value: '', + collection: true + } + + const [show, setShow] = useState(false) + const [ + formValues, setFormValues, initFormValues + ] = useLsState('rdmo.interview.reuse', initialFormValues, ['value', 'collection']) + const [formErrors, setFormErrors] = useState([]) + + const compareValues = (a, b) => ( + (a.set_prefix == b.set_prefix) && + (a.set_index == b.set_index) && + ( + (!isNil(a.option) && (a.option == b.option)) || + (!isEmpty(a.external_id) && (a.external_id == b.external_id)) + ) + ) + + const handleSubmit = () => { + if (isEmpty(formValues.value)) { + setFormErrors({ value: true }) + } else { + values.forEach(value => { + // look for the "same" value in the list of values from the search component + const reusedValue = formValues.value.values.find(reusedValue => compareValues(value, reusedValue)) + if (isNil(reusedValue)) { + // delete the value if it does not exist in the reused value + deleteValue(value) + } else if (value.text != reusedValue.text) { + // update the value if the additional text changed + updateValue(value, { text: reusedValue.text, option: value.option, external_id: value.external_id }) + } + }) + + const newValues = [] + formValues.value.values.forEach(reusedValue => { + // look for the "same" value in the existing values + const value = values.find(value => compareValues(value, reusedValue)) + if (isNil(value)) { + newValues.push({ text: reusedValue.text, option: reusedValue.option, external_id: reusedValue.external_id }) + } + }) + + createValues(newValues) + setShow(false) + } + } + + // init the form values + useEffect(() => { + if (show) { + initFormValues() + } + }, [show]) + + // remove the hasError flag if an inputValue is entered + useEffect(() => { + if (!isEmpty(formValues.value)) { + setFormErrors({ value: false }) + } + }, [formValues, formValues.value]) + + return <> + + + setShow(false)} onSubmit={handleSubmit}> +
+ + + + +

+ {gettext('You can reuse an answer from a similar question in any ' + + 'project you have access to.')} +

+
+
+ +} + +QuestionReuseValues.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + createValues: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired +} + +export default QuestionReuseValues 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..78b81bde9b --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSet.js @@ -0,0 +1,125 @@ +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 QuestionSetCopySet from './QuestionSetCopySet' +import QuestionSetHelp from './QuestionSetHelp' +import QuestionSetHelpTemplate from './QuestionSetHelpTemplate' +import QuestionSetRemoveSet from './QuestionSetRemoveSet' + +const QuestionSet = ({ templates, questionset, sets, values, disabled, isManager, + parentSet, createSet, updateSet, deleteSet, copySet, + createValue, updateValue, deleteValue, copyValue }) => { + + 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} + copySet={copySet} + createValue={createValue} + updateValue={updateValue} + deleteValue={deleteValue} + copyValue={copyValue} + /> + ) + } else { + return ( + ( + value.attribute == element.attribute && + value.set_prefix == set.set_prefix && + value.set_index == set.set_index + ))} + siblings={values.filter((value) => ( + 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} + copyValue={copyValue} + /> + ) + } + }) + ) + } +
+
+ )) + } +
+ + +
+ ) +} + +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, + copySet: PropTypes.func.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired, + copyValue: 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/QuestionSetCopySet.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetCopySet.js new file mode 100644 index 0000000000..3c5b1fc24b --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetCopySet.js @@ -0,0 +1,42 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { last } from 'lodash' + +import Modal from 'rdmo/core/assets/js/components/Modal' + +import useModal from 'rdmo/core/assets/js/hooks/useModal' + +const QuestionCopySet = ({ questionset, sets, currentSet, copySet }) => { + + const modal = useModal() + + const handleCopySet = () => { + copySet(currentSet, null, { + set_prefix: currentSet.set_prefix, + set_index: last(sets) ? last(sets).set_index + 1 : 0, + }) + modal.close() + } + + return questionset.is_collection && ( + <> + + + + + + ) +} + +QuestionCopySet.propTypes = { + questionset: PropTypes.object.isRequired, + sets: PropTypes.array.isRequired, + currentSet: PropTypes.object.isRequired, + copySet: PropTypes.func.isRequired +} + +export default QuestionCopySet 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..de8bc8377b --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetRemoveSet.js @@ -0,0 +1,39 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import useModal from 'rdmo/core/assets/js/hooks/useModal' + +import Modal from 'rdmo/core/assets/js/components/Modal' + +const QuestionRemoveSet = ({ questionset, currentSet, deleteSet }) => { + + const modal = useModal() + + const handleRemoveSet = () => { + deleteSet(currentSet) + modal.close() + } + + return questionset.is_collection && ( + <> + + + +

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

+

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

+
+ + ) +} + +QuestionRemoveSet.propTypes = { + questionset: PropTypes.object.isRequired, + currentSet: PropTypes.object.isRequired, + deleteSet: PropTypes.func.isRequired +} + +export default QuestionRemoveSet 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..23d1311494 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/CheckboxInput.js @@ -0,0 +1,109 @@ +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 handleCreate = (option, additionalInput) => { + if (option.has_provider) { + onCreate([{ + external_id: option.id, + text: option.text + }]) + } else { + onCreate([{ + option: option.id, + text: additionalInput + }]) + } + } + + const handleChange = () => { + if (checked) { + onDelete(value) + } else { + handleCreate(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, { + text: additionalInput, + option: option.id, + unit: question.unit, + value_type: question.value_type + }) + } + } else { + handleCreate(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..7e9d2515af --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/CheckboxWidget.js @@ -0,0 +1,102 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { maxBy } from 'lodash' + +import { gatherOptions } from '../../../utils/options' + +import QuestionCopyValues from '../question/QuestionCopyValues' +import QuestionError from '../question/QuestionError' +import QuestionReuseValues from '../question/QuestionReuseValues' +import QuestionSuccess from '../question/QuestionSuccess' + +import CheckboxInput from './CheckboxInput' + +const CheckboxWidget = ({ question, values, siblings, currentSet, disabled, + createValue, updateValue, deleteValue, copyValue }) => { + + const handleCreateValue = (attrsList) => { + const lastValue = maxBy(values, (v) => v.collection_index) + + let collectionIndex = lastValue ? lastValue.collection_index + 1 : 0 + attrsList.forEach(attrs => { + createValue({ + 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, + ...attrs + }, true) + collectionIndex += 1 + }) + } + + 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, + siblings: PropTypes.array, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired, + copyValue: 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..5446837e46 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/DateWidget.js @@ -0,0 +1,80 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import QuestionAddValue from '../question/QuestionAddValue' +import QuestionCopyValue from '../question/QuestionCopyValue' +import QuestionCopyValues from '../question/QuestionCopyValues' +import QuestionDefault from '../question/QuestionDefault' +import QuestionEraseValue from '../question/QuestionEraseValue' +import QuestionError from '../question/QuestionError' +import QuestionRemoveValue from '../question/QuestionRemoveValue' +import QuestionReuseValue from '../question/QuestionReuseValue' +import QuestionSuccess from '../question/QuestionSuccess' + +import DateInput from './DateInput' + +const DateWidget = ({ question, values, siblings, currentSet, disabled, createValue, updateValue, deleteValue, copyValue }) => { + return ( +
+ { + values.map((value, valueIndex) => { + return ( +
+ + + + + + + +
+ } + /> + +
+ ) + }) + } + + +
+ ) +} + +DateWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + siblings: PropTypes.array, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired, + copyValue: 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..8e37767774 --- /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 QuestionError from '../question/QuestionError' +import QuestionRemoveValue from '../question/QuestionRemoveValue' +import QuestionSuccess from '../question/QuestionSuccess' + +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..f48aed2d67 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/RadioWidget.js @@ -0,0 +1,84 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { gatherOptions } from '../../../utils/options' + +import QuestionAddValue from '../question/QuestionAddValue' +import QuestionCopyValue from '../question/QuestionCopyValue' +import QuestionCopyValues from '../question/QuestionCopyValues' +import QuestionDefault from '../question/QuestionDefault' +import QuestionEraseValue from '../question/QuestionEraseValue' +import QuestionError from '../question/QuestionError' +import QuestionRemoveValue from '../question/QuestionRemoveValue' +import QuestionReuseValue from '../question/QuestionReuseValue' +import QuestionSuccess from '../question/QuestionSuccess' + +import RadioInput from './RadioInput' + +const RadioWidget = ({ question, values, siblings, currentSet, disabled, + createValue, updateValue, deleteValue, copyValue }) => { + return ( +
+ { + values.map((value, valueIndex) => { + return ( +
+ + + + + + + +
+ } + /> + +
+ ) + }) + } + + + + ) +} + +RadioWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + siblings: PropTypes.array, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired, + copyValue: 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..7581e07f5b --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/RangeWidget.js @@ -0,0 +1,94 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { initRange } from '../../../utils/value' + +import QuestionAddValue from '../question/QuestionAddValue' +import QuestionCopyValue from '../question/QuestionCopyValue' +import QuestionCopyValues from '../question/QuestionCopyValues' +import QuestionDefault from '../question/QuestionDefault' +import QuestionEraseValue from '../question/QuestionEraseValue' +import QuestionError from '../question/QuestionError' +import QuestionRemoveValue from '../question/QuestionRemoveValue' +import QuestionReuseValue from '../question/QuestionReuseValue' +import QuestionSuccess from '../question/QuestionSuccess' + +import RangeInput from './RangeInput' + +const RangeWidget = ({ question, values, siblings, currentSet, disabled, + createValue, updateValue, deleteValue, copyValue }) => { + + 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, + siblings: PropTypes.array, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired, + copyValue: 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..e9d080d383 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/widget/TextWidget.js @@ -0,0 +1,78 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import QuestionAddValue from '../question/QuestionAddValue' +import QuestionCopyValue from '../question/QuestionCopyValue' +import QuestionCopyValues from '../question/QuestionCopyValues' +import QuestionDefault from '../question/QuestionDefault' +import QuestionError from '../question/QuestionError' +import QuestionReuseValue from '../question/QuestionReuseValue' +import QuestionRemoveValue from '../question/QuestionRemoveValue' +import QuestionSuccess from '../question/QuestionSuccess' + +import TextInput from './TextInput' + +const TextWidget = ({ question, values, siblings, currentSet, disabled, + createValue, updateValue, deleteValue, copyValue }) => { + return ( +
+ { + values.map((value, valueIndex) => { + return ( +
+ + + + + + +
+ } + /> + +
+ ) + }) + } + + + + ) +} + +TextWidget.propTypes = { + question: PropTypes.object.isRequired, + values: PropTypes.array.isRequired, + siblings: PropTypes.array, + disabled: PropTypes.bool, + currentSet: PropTypes.object.isRequired, + createValue: PropTypes.func.isRequired, + updateValue: PropTypes.func.isRequired, + deleteValue: PropTypes.func.isRequired, + copyValue: 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} +