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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + RDMO + + diff --git a/rdmo/core/assets/js/actions/actionTypes.js b/rdmo/core/assets/js/actions/actionTypes.js new file mode 100644 index 0000000000..1f97c54f3d --- /dev/null +++ b/rdmo/core/assets/js/actions/actionTypes.js @@ -0,0 +1,17 @@ +export const UPDATE_CONFIG = 'UPDATE_CONFIG' +export const DELETE_CONFIG = 'DELETE_CONFIG' + +export const ADD_TO_PENDING = 'ADD_TO_PENDING' +export const REMOVE_FROM_PENDING = 'REMOVE_FROM_PENDING' + +export const FETCH_SETTINGS_ERROR = 'FETCH_SETTINGS_ERROR' +export const FETCH_SETTINGS_INIT = 'FETCH_SETTINGS_INIT' +export const FETCH_SETTINGS_SUCCESS = 'FETCH_SETTINGS_SUCCESS' + +export const FETCH_TEMPLATES_ERROR = 'FETCH_TEMPLATES_ERROR' +export const FETCH_TEMPLATES_INIT = 'FETCH_TEMPLATES_INIT' +export const FETCH_TEMPLATES_SUCCESS = 'FETCH_TEMPLATES_SUCCESS' + +export const FETCH_CURRENT_USER_ERROR = 'FETCH_CURRENT_USER_ERROR' +export const FETCH_CURRENT_USER_INIT = 'FETCH_CURRENT_USER_INIT' +export const FETCH_CURRENT_USER_SUCCESS = 'FETCH_CURRENT_USER_SUCCESS' diff --git a/rdmo/core/assets/js/actions/configActions.js b/rdmo/core/assets/js/actions/configActions.js new file mode 100644 index 0000000000..0aaebe47b9 --- /dev/null +++ b/rdmo/core/assets/js/actions/configActions.js @@ -0,0 +1,9 @@ +import { UPDATE_CONFIG, DELETE_CONFIG } from './actionTypes' + +export function updateConfig(path, value, ls = false) { + return {type: UPDATE_CONFIG, path, value, ls} +} + +export function deleteConfig(path, ls = false) { + return {type: DELETE_CONFIG, path, ls} +} diff --git a/rdmo/core/assets/js/actions/pendingActions.js b/rdmo/core/assets/js/actions/pendingActions.js new file mode 100644 index 0000000000..2ee4aaf514 --- /dev/null +++ b/rdmo/core/assets/js/actions/pendingActions.js @@ -0,0 +1,9 @@ +import { ADD_TO_PENDING, REMOVE_FROM_PENDING } from './actionTypes' + +export function addToPending(item) { + return {type: ADD_TO_PENDING, item} +} + +export function removeFromPending(item) { + return {type: REMOVE_FROM_PENDING, item} +} diff --git a/rdmo/core/assets/js/actions/settingsActions.js b/rdmo/core/assets/js/actions/settingsActions.js new file mode 100644 index 0000000000..d95f6be36a --- /dev/null +++ b/rdmo/core/assets/js/actions/settingsActions.js @@ -0,0 +1,25 @@ +import CoreApi from '../api/CoreApi' + +import { FETCH_SETTINGS_ERROR, FETCH_SETTINGS_INIT, FETCH_SETTINGS_SUCCESS } from './actionTypes' + +export function fetchSettings() { + return function(dispatch) { + dispatch(fetchSettingsInit()) + + return CoreApi.fetchSettings() + .then((settings) => dispatch(fetchSettingsSuccess(settings))) + .catch((errors) => dispatch(fetchSettingsError(errors))) + } +} + +export function fetchSettingsInit() { + return {type: FETCH_SETTINGS_INIT} +} + +export function fetchSettingsSuccess(settings) { + return {type: FETCH_SETTINGS_SUCCESS, settings} +} + +export function fetchSettingsError(errors) { + return {type: FETCH_SETTINGS_ERROR, errors} +} diff --git a/rdmo/core/assets/js/actions/templateActions.js b/rdmo/core/assets/js/actions/templateActions.js new file mode 100644 index 0000000000..90e598e342 --- /dev/null +++ b/rdmo/core/assets/js/actions/templateActions.js @@ -0,0 +1,25 @@ +import CoreApi from '../api/CoreApi' + +import { FETCH_TEMPLATES_ERROR, FETCH_TEMPLATES_INIT, FETCH_TEMPLATES_SUCCESS } from './actionTypes' + +export function fetchTemplates() { + return function(dispatch) { + dispatch(fetchTemplatesInit()) + + return CoreApi.fetchTemplates() + .then((templates) => dispatch(fetchTemplatesSuccess(templates))) + .catch((errors) => dispatch(fetchTemplatesError(errors))) + } +} + +export function fetchTemplatesInit() { + return {type: FETCH_TEMPLATES_INIT} +} + +export function fetchTemplatesSuccess(templates) { + return {type: FETCH_TEMPLATES_SUCCESS, templates} +} + +export function fetchTemplatesError(errors) { + return {type: FETCH_TEMPLATES_ERROR, errors} +} diff --git a/rdmo/core/assets/js/actions/userActions.js b/rdmo/core/assets/js/actions/userActions.js new file mode 100644 index 0000000000..2c2922428d --- /dev/null +++ b/rdmo/core/assets/js/actions/userActions.js @@ -0,0 +1,25 @@ +import AccountsApi from '../api/AccountsApi' + +import { FETCH_CURRENT_USER_ERROR, FETCH_CURRENT_USER_INIT, FETCH_CURRENT_USER_SUCCESS } from './actionTypes' + +export function fetchCurrentUser() { + return function(dispatch) { + dispatch(fetchCurrentUserInit()) + + return AccountsApi.fetchCurrentUser(true) + .then(currentUser => dispatch(fetchCurrentUserSuccess({ currentUser }))) + .catch(error => dispatch(fetchCurrentUserError(error))) + } +} + +export function fetchCurrentUserInit() { + return {type: FETCH_CURRENT_USER_INIT} +} + +export function fetchCurrentUserSuccess(currentUser) { + return {type: FETCH_CURRENT_USER_SUCCESS, currentUser} +} + +export function fetchCurrentUserError(error) { + return {type: FETCH_CURRENT_USER_ERROR, error} +} diff --git a/rdmo/projects/assets/js/api/AccountsApi.js b/rdmo/core/assets/js/api/AccountsApi.js similarity index 100% rename from rdmo/projects/assets/js/api/AccountsApi.js rename to rdmo/core/assets/js/api/AccountsApi.js diff --git a/rdmo/core/assets/js/api/BaseApi.js b/rdmo/core/assets/js/api/BaseApi.js index 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 ( + <> + + +
+
+ + setValues({ ...values, subject: event.target.value })} + /> +
    + { + errors && errors.subject && errors.subject.map((error, errorIndex) => ( +
  • {errors.subject}
  • + )) + } +
+
+
+ +