diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 255aff784f..0f72bb6def 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,7 +61,7 @@ jobs:
with:
node-version: 18
cache: npm
- - run: npm ci && npm run build:prod
+ - run: npm ci && npm run build:dist
# build the wheel
- uses: actions/setup-python@v5
with:
diff --git a/.gitignore b/.gitignore
index 2af3bf59da..612df3afe1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,8 +36,12 @@ dist
rdmo/management/static
-rdmo/projects/static/projects/js/projects.js
+rdmo/core/static/core/js/base*.js
+rdmo/core/static/core/fonts
+rdmo/core/static/core/css/base*.css
+
+rdmo/projects/static/projects/js/*.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..9c2ab2ca27 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,11 @@
{
"name": "rdmo",
"scripts": {
+ "build:dist": "webpack --config webpack.config.js --mode production --env ignore-perf --fail-on-warnings",
"build:prod": "webpack --config webpack.config.js --mode production",
"build": "webpack --config webpack.config.js --mode development",
- "watch": "webpack --config webpack.config.js --mode development --watch"
+ "watch": "webpack --config webpack.config.js --mode development --watch",
+ "lint": "eslint --ext .js rdmo/"
},
"author": "RDMO Arbeitsgemeinschaft ",
"license": "Apache-2.0",
@@ -19,6 +21,7 @@
"classnames": "^2.5.1",
"date-fns": "^3.6.0",
"font-awesome": "4.7.0",
+ "html-to-text": "^9.0.5",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
@@ -27,6 +30,7 @@
"react": "^18.3.1",
"react-bootstrap": "0.33.1",
"react-datepicker": "7.3.0",
+ "react-diff-viewer-continued": "^3.4.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
@@ -36,7 +40,7 @@
"redux": "^4.1.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
- "react-diff-viewer-continued": "^3.4.0"
+ "use-debounce": "^10.0.0"
},
"devDependencies": {
"@babel/cli": "^7.24.1",
diff --git a/pyproject.toml b/pyproject.toml
index 6103cd7faa..6f8cac418f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -113,6 +113,9 @@ issues = "https://github.com/rdmorganiser/rdmo/issues"
repository = "https://github.com/rdmorganiser/rdmo.git"
slack = "https://rdmo.slack.com"
+[project.scripts]
+rdmo-admin = "rdmo.__main__:main"
+
[tool.setuptools.packages.find]
include = ["rdmo*"]
exclude = ["*assets*", "*tests*"]
diff --git a/rdmo/__main__.py b/rdmo/__main__.py
new file mode 100644
index 0000000000..b19f323ed1
--- /dev/null
+++ b/rdmo/__main__.py
@@ -0,0 +1,27 @@
+'''
+Runs rdmo-admin when the rdmo module is run as a script, much like django-admin
+(see: https://github.com/django/django/blob/main/django/__main__.py):
+
+ python -m rdmo check
+
+The main method is added as script in pyproject.toml so that
+
+ rdmo-admin check
+
+works as well. Unlike django-admin, a set of generic settings is used for the
+management scripts.
+'''
+
+import os
+
+from django.core import management
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rdmo.core.management.settings")
+
+
+def main():
+ management.execute_from_command_line()
+
+
+if __name__ == "__main__":
+ main()
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 @@
+
+
+
+
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 b993faaf08..c7d6d76510 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
@@ -48,6 +48,32 @@ class BaseApi {
body: JSON.stringify(data)
}).catch(error => {
throw new ApiError(error.message)
+ }).then(response => {
+ if (response.ok) {
+ if (response.status == 204) {
+ return null
+ } else {
+ 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 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()
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/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..66febc9427
--- /dev/null
+++ b/rdmo/core/assets/scss/base.scss
@@ -0,0 +1,16 @@
+$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 'forms';
+@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/forms.scss b/rdmo/core/assets/scss/forms.scss
new file mode 100644
index 0000000000..3149abb28f
--- /dev/null
+++ b/rdmo/core/assets/scss/forms.scss
@@ -0,0 +1,3 @@
+textarea {
+ resize: vertical;
+}
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..795a6f9228
--- /dev/null
+++ b/rdmo/core/assets/scss/style.scss
@@ -0,0 +1,497 @@
+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 {
+ > 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;
+}
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/management/commands/build.py b/rdmo/core/management/commands/build.py
new file mode 100644
index 0000000000..9e67ff1905
--- /dev/null
+++ b/rdmo/core/management/commands/build.py
@@ -0,0 +1,13 @@
+import subprocess
+import sys
+
+from django.core.management import call_command
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+
+ def handle(self, *args, **options):
+ call_command('npm', 'ci')
+ call_command('npm', 'run', 'build:prod')
+ subprocess.call(['/bin/bash', '-c', f'{sys.executable} -m build'])
diff --git a/rdmo/core/management/commands/clean.py b/rdmo/core/management/commands/clean.py
new file mode 100644
index 0000000000..bd7a3de439
--- /dev/null
+++ b/rdmo/core/management/commands/clean.py
@@ -0,0 +1,53 @@
+import os
+import shutil
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+
+ def add_arguments(self, parser):
+ parser.add_argument('command', choices=[
+ 'all',
+ 'dist',
+ 'media',
+ 'npm',
+ 'python',
+ 'static',
+ ])
+
+ def handle(self, *args, **options):
+ if options['command'] in ['all', 'dist']:
+ self.clean_dir('dist')
+ self.clean_dir('rdmo.egg-info')
+ if options['command'] in ['all', 'media']:
+ self.clean_dir(settings.MEDIA_ROOT)
+ if options['command'] in ['all', 'npm']:
+ self.clean_dir('node_modules')
+ if options['command'] in ['all', 'static']:
+ self.clean_static()
+ if options['command'] in ['all', 'python']:
+ self.clean_python()
+
+ def clean_python(self):
+ for root, dirs, files in os.walk('.'):
+ for dir_name in dirs:
+ if dir_name == '__pycache__':
+ self.clean_dir(os.path.join(root, dir_name), quiet=True)
+
+ def clean_static(self):
+ self.clean_dir(settings.STATIC_ROOT)
+
+ for path in [
+ # 'rdmo/core/static', # TODO: enable after cleanup
+ 'rdmo/management/static',
+ # 'rdmo/projects/static' # TODO: enable after cleanup
+ ]:
+ self.clean_dir(path)
+
+ def clean_dir(self, path, quiet=False):
+ if path and os.path.exists(path):
+ shutil.rmtree(path)
+ if not quiet:
+ print(f'Directory "{path}" has been removed!')
diff --git a/rdmo/core/management/commands/messages.py b/rdmo/core/management/commands/messages.py
new file mode 100644
index 0000000000..aa1703ca72
--- /dev/null
+++ b/rdmo/core/management/commands/messages.py
@@ -0,0 +1,17 @@
+import subprocess
+
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+
+ def add_arguments(self, parser):
+ parser.add_argument('command', choices=['make', 'compile'])
+
+ def handle(self, *args, **options):
+ if options['command'] == 'make':
+ subprocess.check_call(['django-admin', 'makemessages', '--all'], cwd='rdmo')
+ subprocess.check_call(['django-admin', 'makemessages', '--all', '-d', 'djangojs'], cwd='rdmo')
+
+ elif options['command'] == 'compile':
+ subprocess.check_call(['django-admin', 'compilemessages'], cwd='rdmo')
diff --git a/rdmo/core/management/commands/npm.py b/rdmo/core/management/commands/npm.py
new file mode 100644
index 0000000000..5e5929d931
--- /dev/null
+++ b/rdmo/core/management/commands/npm.py
@@ -0,0 +1,22 @@
+import os
+import subprocess
+
+from django.core.management.base import BaseCommand, CommandError
+
+
+class Command(BaseCommand):
+
+ def add_arguments(self, parser):
+ parser.add_argument('command', nargs="*")
+
+ def handle(self, *args, **options):
+ nvm_dir = os.getenv('NVM_DIR')
+
+ if nvm_dir is None:
+ raise CommandError('NVM_DIR is not set, is nvm.sh installed?')
+
+ if not os.path.exists(nvm_dir):
+ raise CommandError('NVM_DIR does not exist, is nvm.sh installed?')
+
+ command = ' '.join(options['command'])
+ subprocess.call(['/bin/bash', '-c', f'source {nvm_dir}/nvm.sh; npm {command}'])
diff --git a/rdmo/core/management/settings.py b/rdmo/core/management/settings.py
new file mode 100644
index 0000000000..aebbda76e3
--- /dev/null
+++ b/rdmo/core/management/settings.py
@@ -0,0 +1,11 @@
+'''
+Generic settings to be used with rdmo-admin outside of an rdmo-app.
+'''
+
+from rdmo.core.settings import * # noqa: F403
+
+ROOT_URLCONF = ''
+
+DATABASES = {}
+
+STATIC_ROOT = 'static_root'
diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py
index 35fa8c667c..63d3b17459 100644
--- a/rdmo/core/settings.py
+++ b/rdmo/core/settings.py
@@ -200,9 +200,11 @@
'MULTISITE',
'GROUPS',
'EXPORT_FORMATS',
+ 'PROJECT_VISIBILITY',
'PROJECT_ISSUES',
'PROJECT_VIEWS',
'PROJECT_EXPORTS',
+ 'PROJECT_SNAPSHOT_EXPORTS',
'PROJECT_IMPORTS',
'PROJECT_IMPORTS_LIST',
'PROJECT_SEND_ISSUE',
@@ -218,7 +220,25 @@
'MULTISITE',
'GROUPS',
'EXPORT_FORMATS',
- 'PROJECT_TABLE_PAGE_SIZE'
+ 'PROJECT_TABLE_PAGE_SIZE',
+ 'PROJECT_CONTACT'
+]
+
+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_contact_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'
@@ -279,12 +299,17 @@
PROJECT_TABLE_PAGE_SIZE = 20
+PROJECT_VISIBILITY = True
+
PROJECT_ISSUES = True
PROJECT_ISSUE_PROVIDERS = []
PROJECT_VIEWS = True
+PROJECT_CONTACT = False
+PROJECT_CONTACT_RECIPIENTS = []
+
PROJECT_EXPORTS = [
('xml', _('RDMO XML'), 'rdmo.projects.exports.RDMOXMLExport'),
('csvcomma', _('CSV (comma separated)'), 'rdmo.projects.exports.CSVCommaExport'),
@@ -292,6 +317,8 @@
('json', _('JSON'), 'rdmo.projects.exports.JSONExport'),
]
+PROJECT_SNAPSHOT_EXPORTS = []
+
PROJECT_IMPORTS = [
('xml', _('RDMO XML'), 'rdmo.projects.imports.RDMOXMLImport'),
]
diff --git a/rdmo/core/static/core/css/base.scss b/rdmo/core/static/core/css/base.scss
index e3519e18fb..1fd9b39b7e 100644
--- a/rdmo/core/static/core/css/base.scss
+++ b/rdmo/core/static/core/css/base.scss
@@ -97,9 +97,12 @@ a {
.btn-link {
color: $link-color;
+ padding: 0;
+ text-decoration: none;
&:hover {
color: $link-color-hover;
+ text-decoration: none;
}
}
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/templates/core/bootstrap_form.html b/rdmo/core/templates/core/bootstrap_form.html
index 6444d2ebf7..4cbced2125 100644
--- a/rdmo/core/templates/core/bootstrap_form.html
+++ b/rdmo/core/templates/core/bootstrap_form.html
@@ -6,6 +6,11 @@
{% include 'core/bootstrap_form_fields.html' %}
+ {% if submit %}
+ {% endif %}
+ {% if delete %}
+
+ {% endif %}
diff --git a/rdmo/core/templates/core/bootstrap_form_field.html b/rdmo/core/templates/core/bootstrap_form_field.html
index f34e8b9454..2104e387d4 100644
--- a/rdmo/core/templates/core/bootstrap_form_field.html
+++ b/rdmo/core/templates/core/bootstrap_form_field.html
@@ -1,3 +1,4 @@
+{% load i18n %}
{% load widget_tweaks %}
{% load core_tags %}
@@ -61,6 +62,12 @@
{% render_field field class="form-control" %}
+ {% if type == 'selectmultiple' %}
+
+ {% trans 'Hold down "Control", or "Command" on a Mac, to select more than one.' %}
+
+ {% endif %}
+
{% endif %}
{% endwith %}
diff --git a/rdmo/core/templatetags/core_tags.py b/rdmo/core/templatetags/core_tags.py
index 52a094b9ee..548ded6d81 100644
--- a/rdmo/core/templatetags/core_tags.py
+++ b/rdmo/core/templatetags/core_tags.py
@@ -114,6 +114,9 @@ def bootstrap_form(context, **kwargs):
if 'submit' in kwargs:
form_context['submit'] = kwargs['submit']
+ if 'delete' in kwargs:
+ form_context['delete'] = kwargs['delete']
+
return render_to_string('core/bootstrap_form.html', form_context, request=context.request)
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/tests/utils.py b/rdmo/core/tests/utils.py
index 3bf4db8c4a..1fce8f674b 100644
--- a/rdmo/core/tests/utils.py
+++ b/rdmo/core/tests/utils.py
@@ -1,3 +1,5 @@
+import hashlib
+
from rdmo.core.models import Model
from rdmo.core.tests.constants import multisite_status_map, status_map_object_permissions
@@ -30,3 +32,7 @@ def get_obj_perms_status_code(instance, username, method):
except KeyError:
# not all users are defined in the method_instance_perms_map
return multisite_status_map[method][username]
+
+
+def compute_checksum(string):
+ return hashlib.sha1(string).hexdigest()
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/utils.py b/rdmo/core/utils.py
index 791917b375..0813b05c74 100644
--- a/rdmo/core/utils.py
+++ b/rdmo/core/utils.py
@@ -418,3 +418,7 @@ def save_metadata(metadata):
f = open(tmp_metadata_file)
log.info('Save metadata file %s %s', tmp_metadata_file, str(metadata))
return tmp_metadata_file
+
+
+def remove_double_newlines(string):
+ return re.sub(r'[\n]{2,}', '\n\n', string)
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/domain/models.py b/rdmo/domain/models.py
index 165b866542..4a3bd408c1 100644
--- a/rdmo/domain/models.py
+++ b/rdmo/domain/models.py
@@ -58,7 +58,7 @@ class Meta:
verbose_name_plural = _('Attributes')
def __str__(self):
- return self.path
+ return self.uri
def save(self, *args, **kwargs):
self.path = self.build_path(self.key, self.parent)
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..07c72480ed 100644
--- a/rdmo/projects/admin.py
+++ b/rdmo/projects/admin.py
@@ -1,7 +1,9 @@
+from django import forms
from django.contrib import admin
from django.db.models import Prefetch
from django.urls import reverse
from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
from .models import (
Continuation,
@@ -14,13 +16,37 @@
Project,
Snapshot,
Value,
+ Visibility,
)
+from .validators import ProjectParentValidator
+
+
+class ProjectAdminForm(forms.ModelForm):
+
+ class Meta:
+ model = Project
+ fields = [
+ 'parent',
+ 'site',
+ 'title',
+ 'description',
+ 'catalog',
+ 'views'
+ ]
+
+
+ def clean(self):
+ super().clean()
+ ProjectParentValidator(self.instance)(self.cleaned_data)
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
+ form = ProjectAdminForm
+
search_fields = ('title', 'user__username')
list_display = ('title', 'owners', 'updated', 'created')
+ readonly_fields = ('progress_count', 'progress_total')
def get_queryset(self, request):
return Project.objects.prefetch_related(
@@ -47,6 +73,25 @@ class ContinuationAdmin(admin.ModelAdmin):
list_display = ('project', 'user', 'page')
+@admin.register(Visibility)
+class VisibilityAdmin(admin.ModelAdmin):
+ search_fields = ('project__title', 'sites', 'groups')
+ list_display = ('project', 'sites_list_display', 'groups_list_display')
+ filter_horizontal = ('sites', 'groups')
+
+ @admin.display(description=_('Sites'))
+ def sites_list_display(self, obj):
+ return _('all Sites') if obj.sites.count() == 0 else ', '.join([
+ site.domain for site in obj.sites.all()
+ ])
+
+ @admin.display(description=_('Groups'))
+ def groups_list_display(self, obj):
+ return _('all Groups') if obj.groups.count() == 0 else ', '.join([
+ group.name for group in obj.groups.all()
+ ])
+
+
@admin.register(Integration)
class IntegrationAdmin(admin.ModelAdmin):
search_fields = ('project__title', 'provider_key')
@@ -105,7 +150,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..4972df8501
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/actions/actionTypes.js
@@ -0,0 +1,66 @@
+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'
+
+export const FETCH_CONTACT_INIT = 'FETCH_CONTACT_INIT'
+export const FETCH_CONTACT_SUCCESS = 'FETCH_CONTACT_SUCCESS'
+export const FETCH_CONTACT_ERROR = 'FETCH_CONTACT_ERROR'
+
+export const SEND_CONTACT_INIT = 'SEND_CONTACT_INIT'
+export const SEND_CONTACT_SUCCESS = 'SEND_CONTACT_SUCCESS'
+export const SEND_CONTACT_ERROR = 'SEND_CONTACT_ERROR'
+
+export const CLOSE_CONTACT = 'CLOSE_CONTACT'
diff --git a/rdmo/projects/assets/js/interview/actions/contactActions.js b/rdmo/projects/assets/js/interview/actions/contactActions.js
new file mode 100644
index 0000000000..472afd6bca
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/actions/contactActions.js
@@ -0,0 +1,84 @@
+import ContactApi from '../api/ContactApi'
+
+import { projectId } from '../utils/meta'
+
+import {
+ FETCH_CONTACT_INIT,
+ FETCH_CONTACT_SUCCESS,
+ FETCH_CONTACT_ERROR,
+ SEND_CONTACT_INIT,
+ SEND_CONTACT_SUCCESS,
+ SEND_CONTACT_ERROR,
+ CLOSE_CONTACT
+} from './actionTypes'
+
+import { addToPending, removeFromPending } from 'rdmo/core/assets/js/actions/pendingActions'
+
+export function fetchContact({ questionset, question, values }) {
+ const pendingId = 'fetchContact'
+
+ return (dispatch, getState) => {
+ const params = {
+ page: getState().interview.page.id,
+ questionset: questionset && questionset.id,
+ question: question && question.id,
+ values: values.filter(value => value.id).map(value => value.id)
+ }
+
+ dispatch(addToPending(pendingId))
+ dispatch(fetchContactInit())
+
+ return ContactApi.fetchContact(projectId, params).then((values) => {
+ dispatch(removeFromPending(pendingId))
+ dispatch(fetchContactSuccess(values))
+ }).catch((error) => {
+ dispatch(removeFromPending(pendingId))
+ dispatch(fetchContactError(error))
+ })
+ }
+}
+
+export function fetchContactInit() {
+ return {type: FETCH_CONTACT_INIT}
+}
+
+export function fetchContactSuccess(values) {
+ return {type: FETCH_CONTACT_SUCCESS, values}
+}
+
+export function fetchContactError(error) {
+ return {type: FETCH_CONTACT_ERROR, error}
+}
+
+export function sendContact(values) {
+ const pendingId = 'sendContact'
+
+ return (dispatch) => {
+ dispatch(addToPending(pendingId))
+ dispatch(sendContactInit())
+
+ return ContactApi.sendContact(projectId, values).then(() => {
+ dispatch(removeFromPending(pendingId))
+ dispatch(sendContactSuccess())
+ }).catch((error) => {
+ dispatch(removeFromPending(pendingId))
+ dispatch(sendContactError(error))
+ })
+ }
+}
+
+export function sendContactInit() {
+ return {type: SEND_CONTACT_INIT}
+}
+
+export function sendContactSuccess() {
+ return {type: SEND_CONTACT_SUCCESS}
+}
+
+export function sendContactError(error) {
+ return {type: SEND_CONTACT_ERROR, error}
+}
+
+export function closeContact() {
+ return {type: CLOSE_CONTACT}
+}
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..53ff451506
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/actions/interviewActions.js
@@ -0,0 +1,693 @@
+import { first, 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 ? page.id : 'done'}`
+
+ 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
+
+ dispatch(addToPending(pendingId))
+ dispatch(storeValueInit(valueIndex))
+
+ return ValueApi.storeValue(projectId, value)
+ .then((value) => {
+ const page = getState().interview.page
+ const sets = getState().interview.sets
+ const question = page.questions.find((question) => question.attribute === value.attribute)
+ const refresh = question && question.optionsets.some((optionset) => optionset.has_refresh)
+
+ dispatch(fetchNavigation(page))
+ dispatch(updateProgress())
+
+ if (refresh) {
+ // if the refresh flag is set, reload all values for the page,
+ // resolveConditions will be called in fetchValues
+ dispatch(fetchValues(page))
+ } else {
+ dispatch(resolveConditions(page, sets))
+ }
+
+ // set the success flag and start the timeout to remove it. the flag is actually
+ // the stored timeout, so we can cancel any old timeout before starting the a new
+ // one in order to prolong the time the indicator is show with each save
+ clearTimeout(valueSuccess)
+ value.success = setTimeout(() => {
+ dispatch(updateValue(value, {success: false}, false))
+ }, 1000)
+
+ // check if there is a file or if a filename is set (when the file was just erased)
+ if (isNil(valueFile) && isNil(value.file_name)) {
+ dispatch(removeFromPending(pendingId))
+ dispatch(storeValueSuccess(value, valueIndex))
+ } else {
+ // upload file after the value is created
+ return ValueApi.storeFile(projectId, value, valueFile)
+ .then((value) => {
+ dispatch(removeFromPending(pendingId))
+ dispatch(storeValueSuccess(value, valueIndex))
+ })
+ .catch((error) => {
+ dispatch(removeFromPending(pendingId))
+ dispatch(storeValueError(error, valueIndex))
+ })
+ }
+ })
+ .catch((error) => {
+ dispatch(removeFromPending(pendingId))
+ dispatch(storeValueError(error, valueIndex))
+ })
+ }
+ }
+}
+
+export function storeValueInit(valueIndex) {
+ return {type: STORE_VALUE_INIT, valueIndex}
+}
+
+export function storeValueSuccess(value, valueIndex) {
+ return {type: STORE_VALUE_SUCCESS, value, valueIndex}
+}
+
+export function storeValueError(error, valueIndex) {
+ return {type: STORE_VALUE_ERROR, error, valueIndex}
+}
+
+export function createValue(attrs, store) {
+ const value = ValueFactory.create(attrs)
+
+ // focus the new value
+ value.focus = true
+
+ if (isNil(store)) {
+ return {type: CREATE_VALUE, value}
+ } else {
+ return storeValue(value)
+ }
+}
+
+export function updateValue(value, attrs, store = true) {
+ if (store) {
+ return storeValue(ValueFactory.update(value, attrs))
+ } else {
+ return {type: UPDATE_VALUE, value, attrs}
+ }
+}
+
+export function copyValue(...originalValues) {
+ const firstValue = first(originalValues)
+ const pendingId = `copyValue/${firstValue.attribute}/${firstValue.set_prefix}/${firstValue.set_index}`
+
+ return (dispatch, getState) => {
+ dispatch(addToPending(pendingId))
+
+ const { sets, values } = getState().interview
+
+ // create copies for each value for all it's empty siblings
+ const copies = originalValues.reduce((copies, value) => {
+ return [
+ ...copies,
+ ...sets.filter((set) => (
+ (set.set_prefix == value.set_prefix) &&
+ (set.set_index != value.set_index)
+ )).map((set) => {
+ const siblingIndex = values.findIndex((v) => (
+ (v.attribute == value.attribute) &&
+ (v.set_prefix == set.set_prefix) &&
+ (v.set_index == set.set_index) &&
+ (v.collection_index == value.collection_index)
+ ))
+
+ const sibling = siblingIndex > 0 ? values[siblingIndex] : null
+
+ if (isNil(sibling)) {
+ return [ValueFactory.create({ ...value, set_index: set.set_index }), siblingIndex]
+ } else if (isEmptyValue(sibling)) {
+ // the spread operator { ...sibling } does prevent an update in place
+ return [ValueFactory.update({ ...sibling }, value), siblingIndex]
+ } else {
+ return null
+ }
+ }).filter((value) => !isNil(value))
+ ]
+ }, [])
+
+ // dispatch storeValueInit for each of the updated values,
+ // created values have valueIndex -1 and will be skipped
+ // eslint-disable-next-line no-unused-vars
+ copies.forEach(([value, valueIndex]) => dispatch(storeValueInit(valueIndex)))
+
+ // loop over all copies and store the values on the server
+ // afterwards fetchNavigation, updateProgress and check refresh once
+ return Promise.all(
+ copies.map(([value, valueIndex]) => {
+ return ValueApi.storeValue(projectId, value)
+ .then((value) => dispatch(storeValueSuccess(value, valueIndex)))
+ })
+ ).then(() => {
+ dispatch(removeFromPending(pendingId))
+
+ const page = getState().interview.page
+ const sets = getState().interview.sets
+ const question = page.questions.find((question) => question.attribute === firstValue.attribute)
+ const refresh = question && question.optionsets.some((optionset) => optionset.has_refresh)
+
+ dispatch(fetchNavigation(page))
+ dispatch(updateProgress())
+
+ if (refresh) {
+ // if the refresh flag is set, reload all values for the page,
+ // resolveConditions will be called in fetchValues
+ dispatch(fetchValues(page))
+ } else {
+ dispatch(resolveConditions(page, sets))
+ }
+ })
+ }
+}
+
+export function deleteValue(value) {
+ const pendingId = `deleteValue/${value.id}`
+
+ if (value.pending) {
+ return {type: NOOP}
+ } else {
+ return (dispatch, getState) => {
+ dispatch(addToPending(pendingId))
+ dispatch(deleteValueInit(value))
+
+ if (isNil(value.id)) {
+ return dispatch(deleteValueSuccess(value))
+ } else {
+ return ValueApi.deleteValue(projectId, value)
+ .then(() => {
+ const page = getState().interview.page
+ const sets = getState().interview.sets
+ const question = page.questions.find((question) => question.attribute === value.attribute)
+ const refresh = question.optionsets.some((optionset) => optionset.has_refresh)
+
+ dispatch(fetchNavigation(page))
+ dispatch(updateProgress())
+
+ if (refresh) {
+ // if the refresh flag is set, reload all values for the page,
+ // resolveConditions will be called in fetchValues
+ dispatch(fetchValues(page))
+ } else {
+ dispatch(resolveConditions(page, sets))
+ }
+
+ dispatch(removeFromPending(pendingId))
+ dispatch(deleteValueSuccess(value))
+ })
+ .catch((errors) => {
+ dispatch(removeFromPending(pendingId))
+ dispatch(deleteValueError(errors))
+ })
+ }
+ }
+ }
+}
+
+export function deleteValueInit(value) {
+ return {type: DELETE_VALUE_INIT, value}
+}
+
+export function deleteValueSuccess(value) {
+ return {type: DELETE_VALUE_SUCCESS, value}
+}
+
+export function deleteValueError(errors) {
+ return {type: DELETE_VALUE_ERROR, errors}
+}
+
+export function activateSet(set) {
+ if (isEmpty(set.set_prefix)) {
+ return updateConfig('page.currentSetIndex', set.set_index, true)
+ } else {
+ return { type: NOOP }
+ }
+}
+
+export function createSet(attrs) {
+ return (dispatch, getState) => {
+ // create a new set
+ const set = SetFactory.create(attrs)
+
+ // create a value for the text if the page has an attribute
+ const value = isNil(attrs.attribute) ? null : ValueFactory.create(attrs)
+
+ // create 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 < sets.length - 1) {
+ dispatch(activateSet(sets[index + 1]))
+ } else {
+ // If it's the last set, activate the new last set
+ dispatch(activateSet(sets[index - 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, currentSetValue, attrs) {
+ const pendingId = `copySet/${currentSet.set_prefix}/${currentSet.set_index}`
+
+ return (dispatch, getState) => {
+ dispatch(addToPending(pendingId))
+ dispatch(copySetInit())
+
+ // 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 copySetCallback = (setValues) => {
+ dispatch(activateSet(set))
+
+ const state = getState().interview
+
+ const page = state.page
+ const values = [...state.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 = isEmpty(set.set_prefix) ? 0 : 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('|').map((sp, idx) => {
+ // for the set_prefix of the new value, set the number at the position, which is one more
+ // than the length of the set_prefix of the new (and old) set, to the set_index of the new set.
+ // since idx counts from 0, this equals setPrefixLength
+ return (idx == setPrefixLength) ? set.set_index : sp
+ }).join('|')
+ }
+
+ delete value.id
+ return ValueApi.storeValue(projectId, value)
+ })
+ )
+ } else {
+ promise = ValueApi.copySet(projectId, currentSetValue, value)
+ }
+
+ 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/ContactApi.js b/rdmo/projects/assets/js/interview/api/ContactApi.js
new file mode 100644
index 0000000000..012996a0e3
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/api/ContactApi.js
@@ -0,0 +1,17 @@
+import { encodeParams } from 'rdmo/core/assets/js/utils/api'
+
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class ContactApi extends BaseApi {
+
+ static fetchContact(projectId, params) {
+ return this.get(`/api/v1/projects/projects/${projectId}/contact/?${encodeParams(params)}`)
+ }
+
+ static sendContact(projectId, data) {
+ return this.post(`/api/v1/projects/projects/${projectId}/contact/`, data)
+ }
+
+}
+
+export default ContactApi
diff --git a/rdmo/projects/assets/js/interview/api/PageApi.js b/rdmo/projects/assets/js/interview/api/PageApi.js
new file mode 100644
index 0000000000..36035c8c34
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/api/PageApi.js
@@ -0,0 +1,21 @@
+import { isNil } from 'lodash'
+
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class ProjectsApi extends BaseApi {
+
+ static fetchPage(projectId, pageId, back) {
+ if (isNil(back)) {
+ return this.get(`/api/v1/projects/projects/${projectId}/pages/${pageId}/`)
+ } else {
+ return this.get(`/api/v1/projects/projects/${projectId}/pages/${pageId}/?back=true`)
+ }
+ }
+
+ static fetchContinue(projectId) {
+ return this.get(`/api/v1/projects/projects/${projectId}/pages/continue/`)
+ }
+
+}
+
+export default ProjectsApi
diff --git a/rdmo/projects/assets/js/interview/api/ProjectApi.js b/rdmo/projects/assets/js/interview/api/ProjectApi.js
new file mode 100644
index 0000000000..4eb136bf3a
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/api/ProjectApi.js
@@ -0,0 +1,47 @@
+import { isNil, last } from 'lodash'
+
+import { encodeParams } from 'rdmo/core/assets/js/utils/api'
+
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class ProjectsApi extends BaseApi {
+
+ static fetchOverview(projectId) {
+ return this.get(`/api/v1/projects/projects/${projectId}/overview/`)
+ }
+
+ static fetchNavigation(projectId, page_id) {
+ if (isNil(page_id)) {
+ return this.get(`/api/v1/projects/projects/${projectId}/navigation/`)
+ } else {
+ return this.get(`/api/v1/projects/projects/${projectId}/navigation/${page_id}/`)
+ }
+ }
+
+ static fetchProgress(projectId) {
+ return this.get(`/api/v1/projects/projects/${projectId}/progress/`)
+ }
+
+ static updateProgress(projectId) {
+ return this.post(`/api/v1/projects/projects/${projectId}/progress/`)
+ }
+
+ static fetchOptions(projectId, optionsetId, searchText) {
+ const params = { optionset: optionsetId, search: searchText || '' }
+ return this.get(`/api/v1/projects/projects/${projectId}/options/?${encodeParams(params)}`)
+ }
+
+ static resolveCondition(projectId, set, element) {
+ const model = last(element.model.split('.'))
+ const params = {
+ set_prefix: set.set_prefix,
+ set_index: set.set_index,
+ [model]: element.id
+ }
+
+ return this.get(`/api/v1/projects/projects/${projectId}/resolve/?${encodeParams(params)}`)
+ }
+
+}
+
+export default ProjectsApi
diff --git a/rdmo/projects/assets/js/interview/api/ValueApi.js b/rdmo/projects/assets/js/interview/api/ValueApi.js
new file mode 100644
index 0000000000..b925e5acac
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/api/ValueApi.js
@@ -0,0 +1,42 @@
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+import { encodeParams } from 'rdmo/core/assets/js/utils/api'
+import isUndefined from 'lodash/isUndefined'
+
+class ValueApi extends BaseApi {
+
+ static fetchValues(projectId, params) {
+ return this.get(`/api/v1/projects/projects/${projectId}/values/?${encodeParams(params)}`)
+ }
+
+ static storeValue(projectId, value) {
+ if (isUndefined(value.id)) {
+ return this.post(`/api/v1/projects/projects/${projectId}/values/`, value)
+ } else {
+ return this.put(`/api/v1/projects/projects/${projectId}/values/${value.id}/`, value)
+ }
+ }
+
+ static storeFile(projectId, value, file) {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ return this.postFormData(`/api/v1/projects/projects/${projectId}/values/${value.id}/file/`, formData)
+ }
+
+ static deleteValue(projectId, value) {
+ if (!isUndefined(value.id)) {
+ return this.delete(`/api/v1/projects/projects/${projectId}/values/${value.id}/`)
+ }
+ }
+
+ static copySet(projectId, currentSetValue, setValue) {
+ return this.post(`/api/v1/projects/projects/${projectId}/values/${currentSetValue.id}/set/`, setValue)
+ }
+
+ 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/Contact.js b/rdmo/projects/assets/js/interview/components/main/Contact.js
new file mode 100644
index 0000000000..84d88109d1
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/components/main/Contact.js
@@ -0,0 +1,82 @@
+import React, { useState, useEffect } from 'react'
+import PropTypes from 'prop-types'
+
+import Modal from 'rdmo/core/assets/js/components/Modal'
+
+import Html from 'rdmo/core/assets/js/components/Html'
+
+const Contact = ({ templates, contact, sendContact, closeContact }) => {
+ const { showModal, values: initialValues, errors } = contact
+
+ const [values, setValues] = useState({})
+
+ useEffect(() => setValues(initialValues), [initialValues])
+
+ const onSubmit = (event) => {
+ event.preventDefault()
+ sendContact(values)
+ }
+
+ const onClose = () => closeContact()
+
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+Contact.propTypes = {
+ templates: PropTypes.object.isRequired,
+ contact: PropTypes.object.isRequired,
+ sendContact: PropTypes.func.isRequired,
+ closeContact: PropTypes.func.isRequired
+}
+
+export default Contact
diff --git a/rdmo/projects/assets/js/interview/components/main/Done.js b/rdmo/projects/assets/js/interview/components/main/Done.js
new file mode 100644
index 0000000000..e34202b28e
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/components/main/Done.js
@@ -0,0 +1,35 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { baseUrl } from 'rdmo/core/assets/js/utils/meta'
+
+import { projectId } from '../../utils/meta'
+
+import Html from 'rdmo/core/assets/js/components/Html'
+
+const Done = ({ templates }) => {
+
+ const projectUrl = `${baseUrl}/projects/${projectId}/`
+ const answersUrl = `${baseUrl}/projects/${projectId}/answers/`
+
+ return (
+ <>
+
+
+
+ {gettext('View answers')}
+
+
+
+ {gettext('Back to project overview')}
+
+ >
+ )
+}
+
+Done.propTypes = {
+ templates: PropTypes.object.isRequired,
+ overview: PropTypes.object.isRequired
+}
+
+export default Done
diff --git a/rdmo/projects/assets/js/interview/components/main/Errors.js b/rdmo/projects/assets/js/interview/components/main/Errors.js
new file mode 100644
index 0000000000..9914553a17
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/components/main/Errors.js
@@ -0,0 +1,44 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { baseUrl } from 'rdmo/core/assets/js/utils/meta'
+
+import Html from 'rdmo/core/assets/js/components/Html'
+
+import { projectId } from '../../utils/meta'
+
+const Errors = ({ templates, errors }) => {
+ const projectUrl = `${baseUrl}/projects/${projectId}/`
+
+ return (
+ <>
+
+
+
+ {
+ errors.map((error, errorIndex) => (
+ -
+ {error.actionType}
+ {error.statusText && <>: {error.statusText}>}
+ {error.status && <>({error.status})>}
+
+ ))
+ }
+
+
+
+ window.location.reload()}>{gettext('Reload page')}
+
+
+ {gettext('Back to project overview')}
+
+ >
+ )
+}
+
+Errors.propTypes = {
+ templates: PropTypes.object.isRequired,
+ errors: PropTypes.array.isRequired
+}
+
+export default Errors
diff --git a/rdmo/projects/assets/js/interview/components/main/page/Page.js b/rdmo/projects/assets/js/interview/components/main/page/Page.js
new file mode 100644
index 0000000000..79a3e59998
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/components/main/page/Page.js
@@ -0,0 +1,135 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import { isNil } from 'lodash'
+
+import Html from 'rdmo/core/assets/js/components/Html'
+
+import Question from '../question/Question'
+import QuestionSet from '../questionset/QuestionSet'
+
+import PageButtons from './PageButtons'
+import PageHead from './PageHead'
+
+const Page = ({ config, settings, templates, overview, page, sets, values, fetchPage, fetchContact,
+ 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 = 0
+ currentSet = sets.find((set) => (set.set_prefix == currentSetPrefix && set.set_index == 0))
+ }
+
+ 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}
+ fetchContact={fetchContact}
+ />
+ )
+ } else {
+ return (
+ (
+ set.set_prefix == currentSetPrefix
+ ))}
+ values={values.filter((value) => (
+ 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}
+ fetchContact={fetchContact}
+ />
+ )
+ }
+ })
+ )
+ }
+
+
+
+
+ )
+}
+
+Page.propTypes = {
+ config: PropTypes.object.isRequired,
+ settings: 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,
+ fetchContact: 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..719afecbd3
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/components/main/page/PageHead.js
@@ -0,0 +1,168 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import { capitalize, isNil, last } from 'lodash'
+
+import Html from 'rdmo/core/assets/js/components/Html'
+import useModal from 'rdmo/core/assets/js/hooks/useModal'
+
+import PageHeadDeleteModal from './PageHeadDeleteModal'
+import PageHeadFormModal from './PageHeadFormModal'
+
+const PageHead = ({ templates, page, sets, values, currentSet,
+ activateSet, createSet, updateSet, deleteSet, copySet }) => {
+
+ const currentSetValue = isNil(currentSet) ? null : (
+ values.find((value) => (
+ value.set_prefix == currentSet.set_prefix && value.set_index == currentSet.set_index
+ ))
+ )
+
+ const {show: showCreateModal, open: openCreateModal, close: closeCreateModal} = useModal()
+ const {show: showUpdateModal, open: openUpdateModal, close: closeUpdateModal} = useModal()
+ const {show: showDeleteModal, open: openDeleteModal, close: closeDeleteModal} = useModal()
+ const {show: showCopyModal, open: openCopyModal, close: closeCopyModal} = useModal()
+
+ const handleActivateSet = (event, set) => {
+ event.preventDefault()
+ if (set.set_index != currentSet.set_index) {
+ activateSet(set)
+ }
+ }
+
+ const handleOpenCreateModal = (event) => {
+ event.preventDefault()
+ openCreateModal()
+ }
+
+ const handleCreateSet = (text) => {
+ createSet({
+ attribute: page.attribute,
+ set_index: last(sets) ? last(sets).set_index + 1 : 0,
+ set_collection: page.is_collection,
+ text
+ })
+ closeCreateModal()
+ }
+
+ const handleUpdateSet = (text) => {
+ updateSet(currentSetValue, { text })
+ closeUpdateModal()
+ }
+
+ const handleDeleteSet = () => {
+ deleteSet(currentSet, currentSetValue)
+ closeDeleteModal()
+ }
+
+ const handleCopySet = (text) => {
+ copySet(currentSet, currentSetValue, {
+ attribute: page.attribute,
+ set_index: last(sets) ? last(sets).set_index + 1 : 0,
+ set_collection: page.is_collection,
+ text
+ })
+ closeCopyModal()
+ }
+
+ return page.is_collection && (
+
+
+
+ {
+ currentSet ? (
+ <>
+
+
+ {
+ page.attribute && (
+
+ )
+ }
+
+
+
+ >
+ ) : (
+
+ )
+ }
+
+
+
+ {
+ currentSetValue && (
+
+ )
+ }
+
+
+ )
+}
+
+PageHead.propTypes = {
+ templates: PropTypes.object.isRequired,
+ page: PropTypes.object.isRequired,
+ sets: PropTypes.array.isRequired,
+ values: PropTypes.array.isRequired,
+ currentSet: PropTypes.object,
+ activateSet: PropTypes.func.isRequired,
+ createSet: PropTypes.func.isRequired,
+ updateSet: PropTypes.func.isRequired,
+ deleteSet: PropTypes.func.isRequired,
+ 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..594c20c771
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/components/main/page/PageHeadDeleteModal.js
@@ -0,0 +1,33 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import Modal from 'rdmo/core/assets/js/components/Modal'
+
+const PageHeadDeleteModal = ({ title, name, show, onClose, onSubmit }) => {
+ return (
+
+ {
+ name ? (
+ %s'), [name])
+ }}>
+ ) : (
+ {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,
+ name: PropTypes.string,
+ 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..fee6feda62
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/components/main/page/PageHeadFormModal.js
@@ -0,0 +1,86 @@
+import React, { useState, useRef, useEffect } from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import { isEmpty, isNil } from 'lodash'
+
+import Modal from 'rdmo/core/assets/js/components/Modal'
+import useFocusEffect from '../../../hooks/useFocusEffect'
+
+
+const PageHeadFormModal = ({ title, submitLabel, submitColor, show, initial, onClose, onSubmit }) => {
+
+ const ref = useRef(null)
+ const [inputValue, setInputValue] = useState('')
+ const [hasError, setHasError] = useState(false)
+
+ const handleSubmit = () => {
+ if (isEmpty(inputValue) && !isNil(initial)) {
+ setHasError(true)
+ } else {
+ onSubmit(inputValue)
+ }
+ }
+
+ // update the inputValue
+ useEffect(() => {
+ if (show) {
+ setInputValue(initial || '')
+ }
+ }, [show])
+
+ // remove the hasError flag if an inputValue is entered
+ useEffect(() => {
+ if (!isEmpty(inputValue)) {
+ setHasError(false)
+ }
+ }, [inputValue])
+
+ // focus when the modal is shown
+ useFocusEffect(ref, show)
+
+ return (
+
+ {
+ isNil(initial) ? (
+
+ {gettext('You can add a new tab using the create button.')}
+
+ ) : (
+
+
+
setInputValue(event.target.value)}
+ onKeyPress={(event) => {
+ if (event.code === 'Enter') {
+ handleSubmit()
+ }
+ }}
+ />
+
+
{gettext('Please give the tab a meaningful name.')}
+
+ )
+ }
+
+ )
+}
+
+PageHeadFormModal.propTypes = {
+ title: PropTypes.string.isRequired,
+ submitLabel: PropTypes.string.isRequired,
+ submitColor: PropTypes.string.isRequired,
+ show: PropTypes.bool.isRequired,
+ initial: PropTypes.string,
+ onClose: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired
+}
+
+export default PageHeadFormModal
diff --git a/rdmo/projects/assets/js/interview/components/main/question/Question.js b/rdmo/projects/assets/js/interview/components/main/question/Question.js
new file mode 100644
index 0000000000..de0472a1c0
--- /dev/null
+++ b/rdmo/projects/assets/js/interview/components/main/question/Question.js
@@ -0,0 +1,61 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { checkQuestion } from '../../../utils/page'
+
+import QuestionAddValueHelp from './QuestionAddValueHelp'
+import QuestionContact from './QuestionContact'
+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 = ({ settings, templates, question, sets, values, siblings, disabled, isManager,
+ currentSet, createValue, updateValue, deleteValue, copyValue, fetchContact }) => {
+ return checkQuestion(question, currentSet) && (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+Question.propTypes = {
+ settings: PropTypes.object.isRequired,
+ templates: PropTypes.object.isRequired,
+ question: PropTypes.object.isRequired,
+ sets: PropTypes.array.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,
+ fetchContact: 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 && (
+