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