diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000000..ea98a15b0d --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,8 @@ +# Browsers that we support: +# https://browserl.ist/?q=last+2+major+versions%2C+%3E+1%25%2C+not+dead%2C+IE+11%2C+Firefox+ESR + +last 2 major versions +> 1% +not dead +IE 11 +Firefox ESR \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index a09fe38bc0..f6ba717a45 100755 --- a/.eslintrc +++ b/.eslintrc @@ -10,12 +10,12 @@ "sourceType": "module", "ecmaFeatures": { "jsx": true - }, + } }, "rules": { "strict": 0, "curly": 0, - "quotes": ["warn", "single"], + "quotes": ["warn", "single", {"avoidEscape": true}], "no-underscore-dangle": 0, "camelcase": [0], "new-cap": 0, diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8570df84e6..3e31f2ca32 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,7 +3,7 @@ 1. [ ] If you've added code that should be tested, add tests 2. [ ] If you've changed APIs, update (or create!) the documentation 3. [ ] Ensure the tests pass -4. [ ] Make sure your code lints and you followed [our coding style](../CONTRIBUTING.md) +4. [ ] Make sure your code lints and you followed [our coding style](https://github.com/kobotoolbox/kpi/blob/master/CONTRIBUTING.md) 5. [ ] If this is a big feature, make sure to prefix the title with `Feature:` and add a thorough description for non-dev folk ## Description diff --git a/.gitignore b/.gitignore index d9f94f98b0..9d53f6317e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ test/compiled/* .idea/ celerybeat-schedule deployments.json -.DS_Store \ No newline at end of file +.DS_Store +jsapp/fonts diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000000..998393dd29 --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,261 @@ +{ + "rules": { + "at-rule-empty-line-before": [ + "always", + { + "except": [ + "blockless-after-same-name-blockless", + "first-nested" + ], + "ignore": ["after-comment"], + "severity": "warning" + } + ], + "at-rule-name-case": ["lower", { "severity": "warning" }], + "at-rule-name-space-after": [ + "always-single-line", + { "severity": "warning" } + ], + "at-rule-semicolon-newline-after": [ + "always", + { "severity": "warning" } + ], + "block-no-empty": true, + "block-closing-brace-empty-line-before": [ + "never", + { "severity": "warning" } + ], + "block-closing-brace-newline-after": [ + "always", + { "severity": "warning" } + ], + "block-closing-brace-newline-before": [ + "always-multi-line", + { "severity": "warning" } + ], + "block-closing-brace-space-before": [ + "always-single-line", + { "severity": "warning" } + ], + "block-opening-brace-newline-after": [ + "always-multi-line", + { "severity": "warning" } + ], + "block-opening-brace-space-after": [ + "always-single-line", + { "severity": "warning" } + ], + "block-opening-brace-space-before": [ + "always", + { "severity": "warning" } + ], + "color-no-invalid-hex": true, + "color-hex-case": ["lower", { "severity": "warning" }], + "color-hex-length": ["short", { "severity": "warning" }], + "comment-no-empty": true, + "comment-empty-line-before": [ + "always", + { + "except": ["first-nested"], + "ignore": ["stylelint-commands"], + "severity": "warning" + } + ], + "comment-whitespace-inside": ["always", { "severity": "warning" }], + "custom-property-empty-line-before": [ + "always", + { + "except": ["after-custom-property", "first-nested"], + "ignore": ["after-comment", "inside-single-line-block"], + "severity": "warning" + } + ], + "declaration-block-no-duplicate-properties": [ + true, + { "severity": "warning" } + ], + "declaration-block-no-shorthand-property-overrides": true, + "declaration-bang-space-after": ["never", { "severity": "warning" }], + "declaration-bang-space-before": ["always", { "severity": "warning" }], + "declaration-block-semicolon-newline-after": [ + "always-multi-line", + { "severity": "warning" } + ], + "declaration-block-semicolon-space-after": [ + "always-single-line", + { "severity": "warning" } + ], + "declaration-block-semicolon-space-before": [ + "never", + { "severity": "warning" } + ], + "declaration-block-single-line-max-declarations": [ + 1, + { "severity": "warning" } + ], + "declaration-block-trailing-semicolon": [ + "always", + { "severity": "warning" } + ], + "declaration-colon-newline-after": [ + "always-multi-line", + { "severity": "warning" } + ], + "declaration-colon-space-after": [ + "always-single-line", + { "severity": "warning" } + ], + "declaration-colon-space-before": ["never", { "severity": "warning" }], + "declaration-empty-line-before": [ + "always", + { + "except": ["after-declaration", "first-nested"], + "ignore": ["after-comment", "inside-single-line-block"], + "severity": "warning" + } + ], + "font-family-no-duplicate-names": true, + "font-family-no-missing-generic-family-keyword": [ + true, + { "severity": "warning" } + ], + "function-calc-no-unspaced-operator": [true, { "severity": "warning" }], + "function-linear-gradient-no-nonstandard-direction": true, + "function-comma-newline-after": [ + "always-multi-line", + { "severity": "warning" } + ], + "function-comma-space-after": [ + "always-single-line", + { "severity": "warning" } + ], + "function-comma-space-before": ["never", { "severity": "warning" }], + "function-max-empty-lines": [0, { "severity": "warning" }], + "function-name-case": ["lower", { "severity": "warning" }], + "function-parentheses-newline-inside": [ + "always-multi-line", + { "severity": "warning" } + ], + "function-parentheses-space-inside": [ + "never-single-line", + { "severity": "warning" } + ], + "function-whitespace-after": ["always", { "severity": "warning" }], + "keyframe-declaration-no-important": [true, { "severity": "warning" }], + "length-zero-no-unit": [true, { "severity": "warning" }], + "max-empty-lines": [1, { "severity": "warning" }], + "media-feature-name-no-unknown": true, + "media-feature-colon-space-after": [ + "always", + { "severity": "warning" } + ], + "media-feature-colon-space-before": [ + "never", + { "severity": "warning" } + ], + "media-feature-name-case": ["lower", { "severity": "warning" }], + "media-feature-parentheses-space-inside": [ + "never", + { "severity": "warning" } + ], + "media-feature-range-operator-space-after": [ + "always", + { "severity": "warning" } + ], + "media-feature-range-operator-space-before": [ + "always", + { "severity": "warning" } + ], + "media-query-list-comma-newline-after": [ + "always-multi-line", + { "severity": "warning" } + ], + "media-query-list-comma-space-after": [ + "always-single-line", + { "severity": "warning" } + ], + "media-query-list-comma-space-before": [ + "never", + { "severity": "warning" } + ], + "no-duplicate-at-import-rules": true, + "no-duplicate-selectors": [true, { "severity": "warning" }], + "no-empty-source": true, + "no-extra-semicolons": [true, { "severity": "warning" }], + "no-eol-whitespace": [true, { "severity": "warning" }], + "no-missing-end-of-source-newline": [true, { "severity": "warning" }], + "no-invalid-double-slash-comments": [true, { "severity": "warning" }], + "number-leading-zero": ["always", { "severity": "warning" }], + "number-no-trailing-zeros": [true, { "severity": "warning" }], + "property-no-unknown": [true, { "severity": "warning" }], + "property-case": ["lower", { "severity": "warning" }], + "rule-empty-line-before": [ + "always-multi-line", + { + "except": ["first-nested"], + "ignore": ["after-comment"], + "severity": "warning" + } + ], + "selector-pseudo-class-no-unknown": true, + "selector-pseudo-element-no-unknown": true, + "selector-type-no-unknown": true, + "selector-attribute-brackets-space-inside": [ + "never", + { "severity": "warning" } + ], + "selector-attribute-operator-space-after": [ + "never", + { "severity": "warning" } + ], + "selector-attribute-operator-space-before": [ + "never", + { "severity": "warning" } + ], + "selector-combinator-space-after": [ + "always", + { "severity": "warning" } + ], + "selector-combinator-space-before": [ + "always", + { "severity": "warning" } + ], + "selector-descendant-combinator-no-non-space": [ + true, + { "severity": "warning" } + ], + "selector-list-comma-newline-after": [ + "always", + { "severity": "warning" } + ], + "selector-list-comma-space-before": [ + "never", + { "severity": "warning" } + ], + "selector-max-empty-lines": [0, { "severity": "warning" }], + "selector-pseudo-class-case": ["lower", { "severity": "warning" }], + "selector-pseudo-class-parentheses-space-inside": [ + "never", + { "severity": "warning" } + ], + "selector-pseudo-element-case": ["lower", { "severity": "warning" }], + "selector-pseudo-element-colon-notation": [ + "double", + { "severity": "warning" } + ], + "selector-type-case": ["lower", { "severity": "warning" }], + "string-no-newline": [true, { "severity": "warning" }], + "unit-no-unknown": true, + "unit-case": ["lower", { "severity": "warning" }], + "value-list-comma-newline-after": [ + "always-multi-line", + { "severity": "warning" } + ], + "value-list-comma-space-after": [ + "always-single-line", + { "severity": "warning" } + ], + "value-list-comma-space-before": ["never", { "severity": "warning" }], + "value-list-max-empty-lines": [0, { "severity": "warning" }] + } +} diff --git a/Dockerfile b/Dockerfile index 8a1e42b9d8..47a819bc32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,7 @@ COPY ./scripts/copy_fonts.py ${KPI_SRC_DIR}/scripts/copy_fonts.py COPY ./scripts/generate_icons.js ${KPI_SRC_DIR}/scripts/generate_icons.js COPY ./webpack ${KPI_SRC_DIR}/webpack COPY ./.eslintrc ${KPI_SRC_DIR}/.eslintrc +COPY ./.stylelintrc.json ${KPI_SRC_DIR}/.stylelintrc.json COPY ./test ${KPI_SRC_DIR}/test COPY ./jsapp ${KPI_SRC_DIR}/jsapp @@ -127,6 +128,8 @@ RUN ln -s "${KPI_SRC_DIR}/docker/init.bash" /etc/my_init.d/10_init_kpi.bash && \ ln -s "${KPI_SRC_DIR}/docker/run_uwsgi.bash" /etc/service/uwsgi/run && \ mkdir -p /etc/service/celery && \ ln -s "${KPI_SRC_DIR}/docker/run_celery.bash" /etc/service/celery/run && \ + mkdir -p /etc/service/celery_beat && \ + ln -s "${KPI_SRC_DIR}/docker/run_celery_beat.bash" /etc/service/celery_beat/run && \ mkdir -p /etc/service/celery_sync_kobocat_xforms && \ ln -s "${KPI_SRC_DIR}/docker/run_celery_sync_kobocat_xforms.bash" /etc/service/celery_sync_kobocat_xforms/run diff --git a/Makefile b/Makefile index b21db21cf0..7412ca0059 100644 --- a/Makefile +++ b/Makefile @@ -8,5 +8,5 @@ pip_compile: $(PIP_DEPENDENCY_TARGETS) # All `pip` dependency files depend on their corresponding `.in` file and the base `requirements.in`. $(PIP_DEPENDENCY_DIR)/%.txt: $(PIP_DEPENDENCY_DIR)/%.in $(PIP_DEPENDENCY_DIR)/requirements.in - pip-compile --output-file=$@ ${ARGS} $< + CUSTOM_COMPILE_COMMAND='make pip_compile' pip-compile --output-file=$@ ${ARGS} $< diff --git a/README.md b/README.md index a0d4e30267..0336a9f27d 100644 --- a/README.md +++ b/README.md @@ -65,4 +65,8 @@ As this is a Django project, you may find the admin panel at `/adm Icons ----- -All project icons are kept in `jsapp/svg-icons/`. Adding new icon requires adding new `svg` file here and regenerating icons with `npm run generate-icons`. Filenames are used for icon font classnames, e.g. `.k-icon-arrow-last` for `arrow-last.svg` (please use kebab-case). You can see all available icons by running `npm run show-icons` - it will open a list in your browser. \ No newline at end of file +All project icons are kept in `jsapp/svg-icons/`. Adding new icon requires adding new `svg` file here and regenerating icons with `npm run generate-icons`. Filenames are used for icon font classnames, e.g. `.k-icon-arrow-last` for `arrow-last.svg` (please use kebab-case). You can see all available icons by running `npm run show-icons` - it will open a list in your browser. + +Supported Browsers +------------------ +See [browsers list config](./.browserslistrc) \ No newline at end of file diff --git a/dependencies/pip/dev_requirements.txt b/dependencies/pip/dev_requirements.txt index ae074e11ed..66ca1c00d4 100644 --- a/dependencies/pip/dev_requirements.txt +++ b/dependencies/pip/dev_requirements.txt @@ -2,99 +2,120 @@ # This file is autogenerated by pip-compile # To update, run: # -# pip-compile --output-file dependencies/pip/dev_requirements.txt dependencies/pip/dev_requirements.in +# make pip_compile # -e git+https://github.com/dimagi/django-digest@0eb1c921329dd187c343b61acfbec4e98450136e#egg=django_digest --e git+https://github.com/kobotoolbox/formpack.git@d87364f951eb17f321957469a189f8dcb3eab5d1#egg=formpack --e git+https://github.com/kobotoolbox/pyxform.git@2.018.19#egg=pyxform -amqp==2.1.4 +-e git+https://github.com/kobotoolbox/formpack.git@45a49bbcc794c6ac3756afe04a4f851c3da9219d#egg=formpack +amqp==2.4.0 anyjson==0.3.3 +argparse==1.4.0 # via unittest2 asn1crypto==0.24.0 # via cryptography +backports.os==0.1.1 # via path.py +bcrypt==3.1.6 # via paramiko begins==0.9 -billiard==3.5.0.2 -boto3==1.5.8 -boto==2.45.0 -botocore==1.8.22 # via boto3, s3transfer -celery==4.0.2 -cffi==1.9.1 # via cryptography +billiard==3.5.0.5 +boto3==1.9.80 +boto==2.49.0 +botocore==1.12.80 # via boto3, s3transfer +celery==4.2.1 +certifi==2018.11.29 # via requests +cffi==1.8.3 # via bcrypt, cryptography, pynacl +chardet==3.0.4 # via requests +configparser==3.7.1 # via importlib-metadata +contextlib2==0.5.5 # via importlib-metadata cookies==2.2.1 # via responses -cryptography==2.2.2 # via paramiko, pyopenssl +cryptography==2.2.2 # via fabric, paramiko, pyopenssl cssselect==1.0.3 # via pyquery cyordereddict==1.0.0 -dj-database-url==0.4.2 +defusedxml==0.5.0 # via djangorestframework-xml +dj-database-url==0.4.1 dj-static==0.0.6 -django-braces==1.11.0 +django-braces==1.13.0 +django-celery-beat==1.1.1 django-constance[database]==2.2.0 -django-debug-toolbar==1.6 -django-extensions==1.7.6 -django-guardian==1.4.1 +django-debug-toolbar==1.4 +django-extensions==1.6.7 django-haystack==2.6.0 django-jsonbfield==0.1.0 django-loginas==0.2.3 django-markitup==3.0.0 django-mptt==0.8.7 -django-oauth-toolkit==0.11.0 +django-oauth-toolkit==0.10.0 django-picklefield==1.0.0 # via django-constance django-private-storage==2.1.2 django-registration-redux==1.3 django-reversion==2.0.8 -django-ses==0.8.1 +django-ses==0.8.9 django-storages==1.6.5 django-taggit==0.22.0 django-toolbelt==0.0.1 -django-webpack-loader==0.4.1 -django==1.8.17 -djangorestframework==3.5.4 -docutils==0.13.1 # via botocore, statistics +django-webpack-loader==0.3.0 +django==1.8.19 +djangorestframework-xml==1.4.0 +djangorestframework==3.6.4 +docutils==0.14 # via botocore, statistics drf-extensions==0.3.1 enum34==1.1.6 # via cryptography -fabric==1.13.1 +fabric==2.4.0 +formencode==1.3.1 # via pyxform funcsigs==1.0.2 # via begins, mock functools32==3.2.3.post2 # via jsonschema -futures==3.1.1 # via s3transfer -gunicorn==19.6.0 -idna==2.2 # via cryptography -ipaddress==1.0.18 # via cryptography +future==0.17.1 # via backports.os, django-ses +futures==3.2.0 # via s3transfer +gunicorn==19.4.5 +idna==2.8 # via cryptography, requests +importlib-metadata==0.8 # via path.py +invoke==1.2.0 # via fabric +ipaddress==1.0.17 # via cryptography jmespath==0.9.3 # via boto3, botocore jsonfield==1.0.3 jsonschema==2.6.0 -kombu==4.0.2 -lxml==4.2.1 -markdown==2.6.8 -mock==2.0.0 # via responses +kombu==4.2.2.post1 +linecache2==1.0.0 # via traceback2 +lxml==4.3.0 +markdown==3.0.1 +mock==2.0.0 ndg-httpsclient==0.4.2 -oauthlib==1.1.2 -paramiko==2.1.1 # via fabric -path.py==11.0.1 +oauthlib==1.0.3 +paramiko==2.4.2 # via fabric +path.py==11.5.0 +pathlib2==2.3.3 # via importlib-metadata pbr==4.0.2 # via mock -psycopg2==2.7.3.2 -py==1.4.32 # via pytest -pyasn1==0.2.2 -pycparser==2.17 # via cffi -pygments==2.2.0 -pymongo==3.4.0 +psycopg2==2.7.7 # via django-jsonbfield, django-toolbelt +py==1.4.31 # via pytest +pyasn1==0.1.9 +pycparser==2.14 # via cffi +pygments==2.1.3 +pymongo==3.7.2 +pynacl==1.3.0 # via paramiko pyopenssl==18.0.0 pyquery==1.4.0 pytest-django==3.1.2 -pytest==3.0.6 # via pytest-django -python-dateutil==2.6.0 +pytest==3.0.3 # via pytest-django +python-dateutil==2.7.5 python-digest==1.7 -pytz==2016.10 -requests==2.13.0 +pytz==2018.9 +pyxform==0.12.0 +requests==2.21.0 responses==0.9.0 -s3transfer==0.1.11 # via boto3 +s3transfer==0.1.13 # via boto3 +scandir==1.9.0 # via pathlib2 shortuuid==0.4.3 -six==1.10.0 -sqlparse==0.2.2 +six==1.12.0 +sqlparse==0.1.19 static3==0.7.0 statistics==1.0.3.5 -tabulate==0.7.7 +tabulate==0.8.2 +traceback2==1.4.0 # via unittest2 unicodecsv==0.14.1 +unittest2==1.1.0 # via pyxform +urllib3==1.24.1 # via botocore, requests uwsgi==2.0.17 -vine==1.1.3 # via amqp -werkzeug==0.11.15 +vine==1.2.0 # via amqp +werkzeug==0.14.1 whitenoise==3.3.1 whoosh==2.7.4 xlrd==1.1.0 -xlsxwriter==1.0.4 -xlwt==1.2.0 +xlsxwriter==1.1.2 +xlwt==1.3.0 +zipp==0.3.3 # via importlib-metadata diff --git a/dependencies/pip/external_services.txt b/dependencies/pip/external_services.txt index d24472ec8a..d88bace226 100644 --- a/dependencies/pip/external_services.txt +++ b/dependencies/pip/external_services.txt @@ -2,33 +2,38 @@ # This file is autogenerated by pip-compile # To update, run: # -# pip-compile --output-file dependencies/pip/external_services.txt dependencies/pip/external_services.in +# make pip_compile # -e git+https://github.com/dimagi/django-digest@0eb1c921329dd187c343b61acfbec4e98450136e#egg=django_digest --e git+https://github.com/kobotoolbox/formpack.git@d87364f951eb17f321957469a189f8dcb3eab5d1#egg=formpack --e git+https://github.com/kobotoolbox/pyxform.git@2.018.19#egg=pyxform -amqp==1.4.9 +-e git+https://github.com/kobotoolbox/formpack.git@45a49bbcc794c6ac3756afe04a4f851c3da9219d#egg=formpack +amqp==2.4.0 anyjson==0.3.3 +argparse==1.4.0 # via unittest2 asn1crypto==0.24.0 # via cryptography +backports.os==0.1.1 # via path.py begins==0.9 -billiard==3.3.0.23 -boto3==1.5.8 -boto==2.40.0 -botocore==1.8.22 # via boto3, s3transfer -celery==3.1.23 +billiard==3.5.0.5 +boto3==1.9.80 +boto==2.49.0 +botocore==1.12.80 # via boto3, s3transfer +celery==4.2.1 +certifi==2018.11.29 # via requests cffi==1.8.3 # via cryptography -contextlib2==0.5.4 # via raven +chardet==3.0.4 # via requests +configparser==3.7.1 # via importlib-metadata +contextlib2==0.5.5 # via importlib-metadata, raven cookies==2.2.1 # via responses cryptography==2.2.2 # via pyopenssl cssselect==1.0.3 # via pyquery cyordereddict==1.0.0 +defusedxml==0.5.0 # via djangorestframework-xml dj-database-url==0.4.1 dj-static==0.0.6 -django-braces==1.8.1 +django-braces==1.13.0 +django-celery-beat==1.1.1 django-constance[database]==2.2.0 django-debug-toolbar==1.4 django-extensions==1.6.7 -django-guardian==1.4.1 django-haystack==2.6.0 django-jsonbfield==0.1.0 django-loginas==0.2.3 @@ -39,63 +44,75 @@ django-picklefield==1.0.0 # via django-constance django-private-storage==2.1.2 django-registration-redux==1.3 django-reversion==2.0.8 -django-ses==0.7.1 +django-ses==0.8.9 django-storages==1.6.5 django-taggit==0.22.0 django-toolbelt==0.0.1 django-webpack-loader==0.3.0 -django==1.8.13 -djangorestframework==3.3.3 -docutils==0.12 # via botocore, statistics +django==1.8.19 +djangorestframework-xml==1.4.0 +djangorestframework==3.6.4 +docutils==0.14 # via botocore, statistics drf-extensions==0.3.1 enum34==1.1.6 # via cryptography +formencode==1.3.1 # via pyxform funcsigs==1.0.2 # via begins, mock functools32==3.2.3.post2 # via jsonschema -futures==3.1.1 # via s3transfer +future==0.17.1 # via backports.os, django-ses +futures==3.2.0 # via s3transfer gunicorn==19.4.5 -idna==2.1 # via cryptography +idna==2.8 # via cryptography, requests +importlib-metadata==0.8 # via path.py ipaddress==1.0.17 # via cryptography jmespath==0.9.3 # via boto3, botocore jsonfield==1.0.3 jsonschema==2.6.0 -kombu==3.0.35 -lxml==4.2.1 -markdown==2.6.6 -mock==2.0.0 # via responses +kombu==4.2.2.post1 +linecache2==1.0.0 # via traceback2 +lxml==4.3.0 +markdown==3.0.1 +mock==2.0.0 ndg-httpsclient==0.4.2 newrelic==2.84.0.64 oauthlib==1.0.3 -path.py==11.0.1 +path.py==11.5.0 +pathlib2==2.3.3 # via importlib-metadata pbr==4.0.2 # via mock -psycopg2==2.7.3.2 +psycopg2==2.7.7 # via django-jsonbfield, django-toolbelt py==1.4.31 # via pytest pyasn1==0.1.9 pycparser==2.14 # via cffi pygments==2.1.3 -pymongo==3.3.0 +pymongo==3.7.2 pyopenssl==18.0.0 pyquery==1.4.0 pytest-django==3.1.2 pytest==3.0.3 # via pytest-django -python-dateutil==2.6.0 +python-dateutil==2.7.5 python-digest==1.7 -pytz==2016.4 +pytz==2018.9 +pyxform==0.12.0 raven==5.32.0 -requests==2.10.0 +requests==2.21.0 responses==0.9.0 -s3transfer==0.1.11 # via boto3 +s3transfer==0.1.13 # via boto3 +scandir==1.9.0 # via pathlib2 shortuuid==0.4.3 -six==1.10.0 +six==1.12.0 sqlparse==0.1.19 static3==0.7.0 statistics==1.0.3.5 -tabulate==0.7.5 +tabulate==0.8.2 +traceback2==1.4.0 # via unittest2 transifex-client==0.11 unicodecsv==0.14.1 -urllib3==1.15.1 # via transifex-client +unittest2==1.1.0 # via pyxform +urllib3==1.24.1 # via botocore, requests, transifex-client uwsgi==2.0.17 +vine==1.2.0 # via amqp whitenoise==3.3.1 whoosh==2.7.4 xlrd==1.1.0 -xlsxwriter==1.0.4 -xlwt==1.0.0 +xlsxwriter==1.1.2 +xlwt==1.3.0 +zipp==0.3.3 # via importlib-metadata diff --git a/dependencies/pip/requirements.in b/dependencies/pip/requirements.in index 8905df2492..1871a7e773 100644 --- a/dependencies/pip/requirements.in +++ b/dependencies/pip/requirements.in @@ -1,20 +1,14 @@ # File for use with `pip-compile`; see https://github.com/nvie/pip-tools # https://github.com/bndr/pipreqs is a handy utility, too. -# Custom pyxform --e git+https://github.com/kobotoolbox/pyxform.git@2.018.19#egg=pyxform - # Formpack --e git+https://github.com/kobotoolbox/formpack.git@d87364f951eb17f321957469a189f8dcb3eab5d1#egg=formpack +-e git+https://github.com/kobotoolbox/formpack.git@45a49bbcc794c6ac3756afe04a4f851c3da9219d#egg=formpack # More up-to-date version of django-digest than PyPI seems to have. # Also, python-digest is an unlisted dependency thereof. python-digest==1.7 -e git+https://github.com/dimagi/django-digest@0eb1c921329dd187c343b61acfbec4e98450136e#egg=django_digest -# django-guardian must match KoBoCAT's version -django-guardian==1.4.1 - # Regular PyPI packages Django<1.9 Markdown @@ -24,10 +18,11 @@ anyjson billiard boto boto3 -celery +celery>=4.0,<5.0 dj-static dj-database-url django-braces +django-celery-beat django-constance[database] django-debug-toolbar django-extensions @@ -46,17 +41,19 @@ django-taggit django-storages django-private-storage djangorestframework +djangorestframework-xml drf-extensions gunicorn jsonfield kombu lxml +mock oauthlib -psycopg2 pymongo pytest-django python-dateutil pytz +pyxform requests responses shortuuid diff --git a/dependencies/pip/requirements.txt b/dependencies/pip/requirements.txt index 03770fa52d..896388999e 100644 --- a/dependencies/pip/requirements.txt +++ b/dependencies/pip/requirements.txt @@ -2,32 +2,38 @@ # This file is autogenerated by pip-compile # To update, run: # -# pip-compile --output-file dependencies/pip/requirements.txt dependencies/pip/requirements.in +# make pip_compile # -e git+https://github.com/dimagi/django-digest@0eb1c921329dd187c343b61acfbec4e98450136e#egg=django_digest --e git+https://github.com/kobotoolbox/formpack.git@d87364f951eb17f321957469a189f8dcb3eab5d1#egg=formpack --e git+https://github.com/kobotoolbox/pyxform.git@2.018.19#egg=pyxform -amqp==1.4.9 +-e git+https://github.com/kobotoolbox/formpack.git@45a49bbcc794c6ac3756afe04a4f851c3da9219d#egg=formpack +amqp==2.4.0 anyjson==0.3.3 +argparse==1.4.0 # via unittest2 asn1crypto==0.24.0 # via cryptography +backports.os==0.1.1 # via path.py begins==0.9 -billiard==3.3.0.23 -boto3==1.5.8 -boto==2.40.0 -botocore==1.8.22 # via boto3, s3transfer -celery==3.1.23 +billiard==3.5.0.5 +boto3==1.9.80 +boto==2.49.0 +botocore==1.12.80 # via boto3, s3transfer +celery==4.2.1 +certifi==2018.11.29 # via requests cffi==1.8.3 # via cryptography +chardet==3.0.4 # via requests +configparser==3.7.1 # via importlib-metadata +contextlib2==0.5.5 # via importlib-metadata cookies==2.2.1 # via responses cryptography==2.2.2 # via pyopenssl cssselect==1.0.3 # via pyquery cyordereddict==1.0.0 +defusedxml==0.5.0 # via djangorestframework-xml dj-database-url==0.4.1 dj-static==0.0.6 -django-braces==1.8.1 +django-braces==1.13.0 +django-celery-beat==1.1.1 django-constance[database]==2.2.0 django-debug-toolbar==1.4 django-extensions==1.6.7 -django-guardian==1.4.1 django-haystack==2.6.0 django-jsonbfield==0.1.0 django-loginas==0.2.3 @@ -38,59 +44,72 @@ django-picklefield==1.0.0 # via django-constance django-private-storage==2.1.2 django-registration-redux==1.3 django-reversion==2.0.8 -django-ses==0.7.1 +django-ses==0.8.9 django-storages==1.6.5 django-taggit==0.22.0 django-toolbelt==0.0.1 django-webpack-loader==0.3.0 -django==1.8.13 -djangorestframework==3.3.3 -docutils==0.12 # via botocore, statistics +django==1.8.19 +djangorestframework-xml==1.4.0 +djangorestframework==3.6.4 +docutils==0.14 # via botocore, statistics drf-extensions==0.3.1 enum34==1.1.6 # via cryptography +formencode==1.3.1 # via pyxform funcsigs==1.0.2 # via begins, mock functools32==3.2.3.post2 # via jsonschema -futures==3.1.1 # via s3transfer +future==0.17.1 # via backports.os, django-ses +futures==3.2.0 # via s3transfer gunicorn==19.4.5 -idna==2.1 # via cryptography +idna==2.8 # via cryptography, requests +importlib-metadata==0.8 # via path.py ipaddress==1.0.17 # via cryptography jmespath==0.9.3 # via boto3, botocore jsonfield==1.0.3 jsonschema==2.6.0 -kombu==3.0.35 -lxml==4.2.1 -markdown==2.6.6 -mock==2.0.0 # via responses +kombu==4.2.2.post1 +linecache2==1.0.0 # via traceback2 +lxml==4.3.0 +markdown==3.0.1 +mock==2.0.0 ndg-httpsclient==0.4.2 oauthlib==1.0.3 -path.py==11.0.1 +path.py==11.5.0 +pathlib2==2.3.3 # via importlib-metadata pbr==4.0.2 # via mock -psycopg2==2.7.3.2 +psycopg2==2.7.7 # via django-jsonbfield, django-toolbelt py==1.4.31 # via pytest pyasn1==0.1.9 pycparser==2.14 # via cffi pygments==2.1.3 -pymongo==3.3.0 +pymongo==3.7.2 pyopenssl==18.0.0 pyquery==1.4.0 pytest-django==3.1.2 pytest==3.0.3 # via pytest-django -python-dateutil==2.6.0 +python-dateutil==2.7.5 python-digest==1.7 -pytz==2016.4 -requests==2.10.0 +pytz==2018.9 +pyxform==0.12.0 +requests==2.21.0 responses==0.9.0 -s3transfer==0.1.11 # via boto3 +s3transfer==0.1.13 # via boto3 +scandir==1.9.0 # via pathlib2 shortuuid==0.4.3 -six==1.10.0 +six==1.12.0 sqlparse==0.1.19 static3==0.7.0 statistics==1.0.3.5 -tabulate==0.7.5 +tabulate==0.8.2 +traceback2==1.4.0 # via unittest2 unicodecsv==0.14.1 +unittest2==1.1.0 # via pyxform +urllib3==1.24.1 # via botocore, requests uwsgi==2.0.17 +vine==1.2.0 # via amqp whitenoise==3.3.1 whoosh==2.7.4 xlrd==1.1.0 -xlsxwriter==1.0.4 -xlwt==1.0.0 +xlsxwriter==1.1.2 +xlwt==1.3.0 +zipp==0.3.3 # via importlib-metadata diff --git a/docker/run_celery.bash b/docker/run_celery.bash index 8c76c74397..10ab4e20e4 100755 --- a/docker/run_celery.bash +++ b/docker/run_celery.bash @@ -4,7 +4,7 @@ source /etc/profile # Run the main Celery worker (will not process `sync_kobocat_xforms` jobs). cd "${KPI_SRC_DIR}" -exec celery worker -A kobo --beat --loglevel=info \ +exec celery worker -A kobo --loglevel=info \ --hostname=main_worker@%h \ --logfile=${KPI_LOGS_DIR}/celery.log \ --pidfile=/tmp/celery.pid \ diff --git a/docker/run_celery_beat.bash b/docker/run_celery_beat.bash new file mode 100755 index 0000000000..cbd4bd6c55 --- /dev/null +++ b/docker/run_celery_beat.bash @@ -0,0 +1,10 @@ +#!/bin/bash +set -e +source /etc/profile + +# Run the main Celery worker (will not process `sync_kobocat_xforms` jobs). +cd "${KPI_SRC_DIR}" +exec celery beat -A kobo --loglevel=info \ + --logfile=${KPI_LOGS_DIR}/celery_beat.log \ + --pidfile=/tmp/celery_beat.pid \ + --scheduler django_celery_beat.schedulers:DatabaseScheduler diff --git a/docker/run_celery_sync_kobocat_xforms.bash b/docker/run_celery_sync_kobocat_xforms.bash index f4ad81fe02..5969cf72e2 100755 --- a/docker/run_celery_sync_kobocat_xforms.bash +++ b/docker/run_celery_sync_kobocat_xforms.bash @@ -12,6 +12,4 @@ exec celery worker -A kobo --loglevel=info \ --pidfile=/tmp/celery_sync_kobocat_xforms.pid \ --queues=sync_kobocat_xforms_queue \ --concurrency=1 \ - --maxtasksperchild=1 - # Watch out: this may be changed in 4.x to `--max-tasks-per-child` per - # http://docs.celeryproject.org/en/latest/reference/celery.bin.worker.html#cmdoption-celery-worker-max-tasks-per-child + --max-tasks-per-child=1 diff --git a/fabfile/__init__.py b/fabfile/__init__.py deleted file mode 100644 index 8a9fef770f..0000000000 --- a/fabfile/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .docker import deploy, publish_docker_image diff --git a/fabfile/docker.py b/fabfile/docker.py deleted file mode 100644 index b1adf039fe..0000000000 --- a/fabfile/docker.py +++ /dev/null @@ -1,261 +0,0 @@ -import os -import re -import json -import tempfile - -from fabric.api import ( - abort, - cd, - env, - hide, - lcd, - local, - prompt, - run, - settings, - sudo, -) -from fabric.contrib import files - - -SERVICE_NAME = 'kpi' -GIT_REPO = 'https://github.com/kobotoolbox/{}.git'.format(SERVICE_NAME) -DOCKER_HUB_REPO = 'kobotoolbox/{}'.format(SERVICE_NAME) -DOCKER_COMPOSE_IMAGE_UPDATE_PATTERN = re.compile( - r'^( *image: *){}.*$'.format(DOCKER_HUB_REPO) -) -CONTAINER_SRC_DIR_ENV_VAR = '{}_SRC_DIR'.format(SERVICE_NAME.upper()) -UPDATE_STATIC_FILE = '{}/LAST_UPDATE.txt'.format(SERVICE_NAME) -# These may be defined in deployments.json -DEPLOYMENT_SETTINGS = ( - 'host_string', # user@host for SSH connection - 'docker_config_path', # Location must house `docker_compose.yml` - ####### For deploying pre-built images ####### - 'docker_git_compose_file', # YML file to update with tag being deployed - 'docker_git_repo', # Git repo housing Docker Compose YML file - 'docker_git_branch', # Branch to update when committing YML change - 'docker_compose_command', # Docker Compose invocation to use when deploying - # (include options like `-f`, but do not include - # commands like `up`) - ####### For building images from source in situ ####### - 'build_root', # Temporary location for cloning repo; deleted at end - 'static_path' # `UPDATE_STATIC_FILE` will be written here -) - -DEPLOYMENTS = {} -IMPORTED_DEPLOYMENTS = {} -deployments_file = os.environ.get('DEPLOYMENTS_JSON', 'deployments.json') -if os.path.exists(deployments_file): - with open(deployments_file, 'r') as f: - IMPORTED_DEPLOYMENTS = json.load(f) -else: - raise Exception("Cannot find {}".format(deployments_file)) - - -def run_no_pty(*args, **kwargs): - # Avoids control characters being returned in the output - kwargs['pty'] = False - return run(*args, **kwargs) - - -def sudo_no_pty(*args, **kwargs): - # Avoids control characters being returned in the output - kwargs['pty'] = False - return sudo(*args, **kwargs) - - -def setup_env(deployment_name): - deployment = DEPLOYMENTS.get(deployment_name, {}) - - if deployment_name in IMPORTED_DEPLOYMENTS: - deployment.update(IMPORTED_DEPLOYMENTS[deployment_name]) - - unrecognized_settings = set(deployment.keys()) - set(DEPLOYMENT_SETTINGS) - if unrecognized_settings: - raise Exception('Unrecognized deployment settings in {}: {}'.format( - deployments_file, ','.join(unrecognized_settings)) - ) - env.update(deployment) - - -def check_required_settings(required_settings): - for required_setting in required_settings: - if required_setting not in env: - raise Exception('Please define {} in {} and try again'.format( - required_setting, deployments_file)) - - -def get_base_image_from_dockerfile(): - from_line = run_no_pty("sed -n '/^FROM /p;q' Dockerfile") - base_image_name = from_line.strip().split(' ')[-1] - return base_image_name - - -def deploy(deployment_name, tag_or_branch): - setup_env(deployment_name) - if 'docker_git_repo' in env: - check_required_settings(( - 'docker_git_compose_file', - 'docker_git_repo', - 'docker_git_branch', - 'docker_compose_command', - )) - commit_pull_and_deploy(tag_or_branch) - else: - check_required_settings(( - 'build_root', - 'docker_config_path', - 'static_path', - )) - build_and_deploy(tag_or_branch) - - -def commit_pull_and_deploy(tag): - # Clone the Docker configuration in a local temporary directory - local_tmpdir = tempfile.mkdtemp(prefix='fab-deploy') - local_compose_file = os.path.join( - local_tmpdir, env.docker_git_compose_file) - with lcd(local_tmpdir): - local("git clone --quiet --depth=1 --branch='{}' '{}' .".format( - env.docker_git_branch, env.docker_git_repo) - ) - # Update the image tag used by Docker Compose - image_name = '{}:{}'.format(DOCKER_HUB_REPO, tag) - updated_compose_image = False - with open(local_compose_file, 'r') as f: - compose_file_lines = f.readlines() - with open(local_compose_file, 'w') as f: - for line in compose_file_lines: - matches = re.match(DOCKER_COMPOSE_IMAGE_UPDATE_PATTERN, line) - if not matches: - f.write(line) - continue - else: - # https://docs.python.org/2/library/os.html#os.linesep - f.write('{prefix}{image_name}\n'.format( - prefix=matches.group(1), image_name=image_name) - ) - updated_compose_image = True - if not updated_compose_image: - raise Exception( - 'Failed to update image to {} in Docker Compose ' - 'configuration'.format(image_name) - ) - # Did we actually make a change? - if local('git diff', capture=True): - # Commit the change - local("git add '{}'".format(local_compose_file)) - local("git commit -am 'Upgrade {service} to {tag}'".format( - service=SERVICE_NAME, tag=tag) - ) - # Push the commit - local('git show') - response = prompt( - 'OK to push the above commit to {} branch of {}? (y/n)'.format( - env.docker_git_branch, env.docker_git_repo) - ) - if response != 'y': - abort('Push cancelled') - local("git push origin '{}'".format(env.docker_git_branch)) - # Make a note of the commit to verify later that it's pulled to the - # remote server - pushed_config_commit = local("git show --no-patch", capture=True) - - # Deploy to the remote server - with cd(env.docker_config_path): - run('git pull') - pulled_config_commit = run_no_pty("git show --no-patch") - if pulled_config_commit != pushed_config_commit: - raise Exception( - 'The configuration commit on the remote server does not match ' - 'what was pushed locally. Please make sure {} is checked out ' - 'on the remote server.'.format(env.docker_git_branch) - ) - run_no_pty("{doco} pull '{service}'".format( - doco=env.docker_compose_command, service=SERVICE_NAME) - ) - run("{doco} up -d".format(doco=env.docker_compose_command)) - - -def build_and_deploy(branch): - build_dir = os.path.join(env.build_root, SERVICE_NAME) - with cd(build_dir): - # Start from scratch - run("find -delete") - # Shallow clone the requested branch to a temporary directory - run("git clone --quiet --depth=1 --branch='{}' '{}' .".format( - branch, GIT_REPO)) - # Note which commit is at the tip of the cloned branch - cloned_commit = run_no_pty("git show --no-patch") - # Update the base image - run_no_pty("docker pull '{}'".format(get_base_image_from_dockerfile())) - with cd(env.docker_config_path): - # Build the image - run("docker-compose build '{}'".format(SERVICE_NAME)) - # Don't specify a service name to avoid "Cannot link to a non running - # container" - run("docker-compose up -d") - running_commit = run_no_pty( - "docker exec $(docker-compose ps -q '{service}') bash -c '" - "cd \"${src_dir_var}\" && git show --no-patch'".format( - service=SERVICE_NAME, - src_dir_var=CONTAINER_SRC_DIR_ENV_VAR - ) - ) - with cd(env.static_path): - # Write the date and running commit to a publicly-accessible file - sudo("(date; echo) > '{}'".format(UPDATE_STATIC_FILE)) - files.append(UPDATE_STATIC_FILE, running_commit.decode('utf-8'), use_sudo=True) - if running_commit != cloned_commit: - raise Exception( - 'The running commit does not match the tip of the cloned ' - 'branch! Make sure docker-compose.yml is set to build from ' - '{}'.format(build_dir) - ) - - -def publish_docker_image(tag, deployment_name='_image_builder'): - def _get_commit_from_docker_image(image_name): - with hide('output'): - return run_no_pty( - "docker run --rm {image_name} bash -c '" - "cd \"${src_dir_var}\" && git show --no-patch'".format( - image_name=image_name, - src_dir_var=CONTAINER_SRC_DIR_ENV_VAR - ) - ) - - setup_env(deployment_name) - check_required_settings(('build_root',)) - build_dir = os.path.join(env.build_root, SERVICE_NAME, tag) - image_name = '{}:{}'.format(DOCKER_HUB_REPO, tag) - - run("mkdir -p '{}'".format(build_dir)) - with cd(build_dir): - # Start from scratch - run("find -delete") - # Shallow clone the requested tag to a temporary directory - with hide('output'): - run("git clone --quiet --depth=1 --branch='{}' '{}' .".format( - tag, GIT_REPO)) - # Note which commit is at the tip of the cloned tag - cloned_commit = run_no_pty("git show --no-patch") - # Check if a suitable image was built already - with settings(warn_only=True): - commit_inside_image = _get_commit_from_docker_image(image_name) - if commit_inside_image != cloned_commit: - # Update the base image - run_no_pty("docker pull '{}'".format( - get_base_image_from_dockerfile() - )) - # Build the image - run("docker build -t '{}' .".format(image_name)) - # Make sure the resulting image has the expected code - commit_inside_image = _get_commit_from_docker_image(image_name) - if commit_inside_image != cloned_commit: - raise Exception( - 'The code inside the built image does not match the ' - 'specified tag. This script is probably broken.' - ) - # Push the image to Docker Hub - run_no_pty("docker push '{}'".format(image_name)) diff --git a/hub/actions.py b/hub/actions.py index 9f1404c316..2d2cd7b1de 100644 --- a/hub/actions.py +++ b/hub/actions.py @@ -1,8 +1,7 @@ -""" -Mostly copied from django/contrib/admin/actions.py -""" - +import requests from collections import OrderedDict +from django.conf import settings +from django.http import HttpResponse from django.core.exceptions import PermissionDenied from django.contrib import messages from django.contrib.admin import helpers @@ -13,7 +12,7 @@ from django.utils.encoding import force_text from django.utils.translation import ugettext_lazy, ugettext as _ -from kpi.deployment_backends.kc_access.shadow_models import _ReadOnlyModel +from kpi.deployment_backends.kc_access.shadow_models import _ShadowModel def delete_related_objects(modeladmin, request, queryset): """ @@ -24,6 +23,8 @@ def delete_related_objects(modeladmin, request, queryset): childs (foreignkeys), a "permission denied" message. Next, it deletes all related objects and redirects back to the change list. + + The code is mostly copied from django/contrib/admin/actions.py """ opts = modeladmin.model._meta app_label = opts.app_label @@ -47,7 +48,7 @@ def delete_related_objects(modeladmin, request, queryset): # element. We can skip it since delete() on the first # level of related objects will cascade. continue - elif not isinstance(obj, _ReadOnlyModel): + elif not isinstance(obj, _ShadowModel): first_level_related_objects.append(obj) # Populate deletable_objects, a data structure of (string representations @@ -109,4 +110,39 @@ def delete_related_objects(modeladmin, request, queryset): context, current_app=modeladmin.admin_site.name) delete_related_objects.short_description = ugettext_lazy( - "Remove related objects for these %(verbose_name_plural)s") + "Remove related objects for these %(verbose_name_plural)s " + "(deletion step 1)") + + +def remove_from_kobocat(modeladmin, kpi_request, queryset): + ''' + This is a hack to try and make administrators' lives less miserable when + they need to delete users. It proxies the initial delete request to KoBoCAT + and returns the confirmation response, mangling the HTML form action so + that clicking "Yes, I'm sure" POSTs to KoBoCAT instead of KPI. + ''' + if not kpi_request.user.is_superuser: + raise PermissionDenied + if kpi_request.method != 'POST': + raise NotImplementedError + post_data = dict(kpi_request.POST) + post_data['action'] = 'delete_selected' + kc_url = settings.KOBOCAT_URL + kpi_request.path + kc_response = requests.post(kc_url, data=post_data, + cookies=kpi_request.COOKIES) + our_response = HttpResponse() + our_response.status_code = kc_response.status_code + # I'm sorry. If something is going to break, it's probably this. + find_text = '
- - - - - - - - - - - diff --git a/jsapp/img/tick.svg b/jsapp/img/tick.svg deleted file mode 100644 index b3d6ec8555..0000000000 --- a/jsapp/img/tick.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/jsapp/index.html b/jsapp/index.html deleted file mode 100644 index c571def025..0000000000 --- a/jsapp/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - KPI - - - - - - -
- - diff --git a/jsapp/js/actions.es6 b/jsapp/js/actions.es6 index 459ba71d2f..c13fe4ddc2 100644 --- a/jsapp/js/actions.es6 +++ b/jsapp/js/actions.es6 @@ -22,14 +22,6 @@ actions.navigation = Reflux.createActions([ ]); actions.auth = Reflux.createActions({ - login: { - children: [ - 'loggedin', - 'passwordfail', - 'anonymous', - 'failed' - ] - }, verifyLogin: { children: [ 'loggedin', @@ -73,24 +65,6 @@ actions.search = Reflux.createActions({ 'failed' ] }, - assetsWithTags: { - children: [ - 'completed', - 'failed' - ] - }, - tags: { - children: [ - 'completed', - 'failed' - ] - }, - libraryDefaultQuery: { - children: [ - 'completed', - 'failed' - ] - }, collections: { children: [ 'completed', @@ -100,18 +74,6 @@ actions.search = Reflux.createActions({ }); actions.resources = Reflux.createActions({ - listAssets: { - children: [ - 'completed', - 'failed' - ] - }, - listSurveys: { - children: [ - 'completed', - 'failed' - ] - }, listCollections: { children: [ 'completed', @@ -178,12 +140,6 @@ actions.resources = Reflux.createActions({ 'failed' ] }, - readCollection: { - children: [ - 'completed', - 'failed' - ] - }, updateCollection: { asyncResult: true }, @@ -265,6 +221,16 @@ actions.permissions = Reflux.createActions({ }, }); +actions.hooks = Reflux.createActions({ + getAll: {children: ['completed', 'failed']}, + add: {children: ['completed', 'failed']}, + update: {children: ['completed', 'failed']}, + delete: {children: ['completed', 'failed']}, + getLogs: {children: ['completed', 'failed']}, + retryLog: {children: ['completed', 'failed']}, + retryLogs: {children: ['completed', 'failed']}, +}); + actions.misc = Reflux.createActions({ checkUsername: { asyncResult: true, @@ -356,15 +322,6 @@ actions.resources.createImport.completed.listen(function(contents){ } }); -actions.resources.createAsset.listen(function(){ - console.error(`use actions.resources.createImport - or actions.resources.createResource.`); -}); - -actions.resources.createResource.failed.listen(function(){ - log('createResourceFailed'); -}); - actions.resources.createSnapshot.listen(function(details){ dataInterface.createAssetSnapshot(details) .done(actions.resources.createSnapshot.completed) @@ -385,10 +342,10 @@ actions.resources.listTags.completed.listen(function(results){ actions.resources.updateAsset.listen(function(uid, values, params={}) { dataInterface.patchAsset(uid, values) - .done(function(asset){ - actions.resources.updateAsset.completed(asset); - if (params.onComplete) { - params.onComplete(asset); + .done((asset) => { + actions.resources.updateAsset.completed(asset, uid, values); + if (typeof params.onComplete === 'function') { + params.onComplete(asset, uid, values); } notify(t('successfully updated')); }) @@ -400,47 +357,23 @@ actions.resources.updateAsset.listen(function(uid, values, params={}) { }); }); -actions.resources.deployAsset.listen( - function(asset, redeployment, dialog_or_alert, params={}){ - var onComplete; - if (params && params.onComplete) { - onComplete = params.onComplete; - } - dataInterface.deployAsset(asset, redeployment) - .done((data) => { - actions.resources.deployAsset.completed(data, dialog_or_alert); - if (onComplete) { - onComplete(asset); - } - }) - .fail((data) => { - actions.resources.deployAsset.failed(data, dialog_or_alert); - }); - } -); - -actions.resources.deployAsset.completed.listen(function(data, dialog_or_alert){ - // close the dialog/alert. - // (this was sometimes failing. possibly dialog already destroyed?) - if (dialog_or_alert) { - if (typeof dialog_or_alert.destroy === 'function') { - dialog_or_alert.destroy(); - } else if (typeof dialog_or_alert.dismiss === 'function') { - dialog_or_alert.dismiss(); - } - } +actions.resources.deployAsset.listen(function(asset, redeployment, params={}){ + dataInterface.deployAsset(asset, redeployment) + .done((data) => { + actions.resources.deployAsset.completed(data.asset); + if (typeof params.onDone === 'function') { + params.onDone(data, redeployment); + } + }) + .fail((data) => { + actions.resources.deployAsset.failed(data, redeployment); + if (typeof params.onFail === 'function') { + params.onFail(data, redeployment); + } + }); }); -actions.resources.deployAsset.failed.listen(function(data, dialog_or_alert){ - // close the dialog/alert. - // (this was sometimes failing. possibly dialog already destroyed?) - if (dialog_or_alert) { - if (typeof dialog_or_alert.destroy === 'function') { - dialog_or_alert.destroy(); - } else if (typeof dialog_or_alert.dismiss === 'function') { - dialog_or_alert.dismiss(); - } - } +actions.resources.deployAsset.failed.listen(function(data, redeployment){ // report the problem to the user let failure_message = null; @@ -477,22 +410,20 @@ actions.resources.deployAsset.failed.listen(function(data, dialog_or_alert){ alertify.alert(t('unable to deploy'), failure_message); }); -actions.resources.setDeploymentActive.listen( - function(details, params={}) { - var onComplete; - if (params && params.onComplete) { - onComplete = params.onComplete; - } - dataInterface.setDeploymentActive(details) - .done(function(/*result*/){ - actions.resources.setDeploymentActive.completed(details); - if (onComplete) { - onComplete(details); - } - }) - .fail(actions.resources.setDeploymentActive.failed); +actions.resources.setDeploymentActive.listen(function(details) { + dataInterface.setDeploymentActive(details) + .done((data) => { + actions.resources.setDeploymentActive.completed(data.asset); + }) + .fail(actions.resources.setDeploymentActive.failed); +}); +actions.resources.setDeploymentActive.completed.listen((result) => { + if (result.deployment__active) { + notify(t('Project unarchived successfully')); + } else { + notify(t('Project archived successfully')); } -); +}); actions.resources.getAssetFiles.listen(function(assetId) { dataInterface @@ -575,15 +506,11 @@ actions.resources.createResource.listen(function(details){ }); actions.resources.deleteAsset.listen(function(details, params={}){ - var onComplete; - if (params && params.onComplete) { - onComplete = params.onComplete; - } dataInterface.deleteAsset(details) - .done(function(/*result*/){ + .done(() => { actions.resources.deleteAsset.completed(details); - if (onComplete) { - onComplete(details); + if (typeof params.onComplete === 'function') { + params.onComplete(details); } }) .fail((err) => { @@ -595,21 +522,19 @@ actions.resources.deleteAsset.listen(function(details, params={}){ }); }); -actions.resources.readCollection.listen(function(details){ - dataInterface.readCollection(details) - .done(actions.resources.readCollection.completed) - .fail(function(req, err, message){ - actions.resources.readCollection.failed(details, req, err, message); - }); -}); - -actions.resources.deleteCollection.listen(function(details){ +actions.resources.deleteCollection.listen(function(details, params = {}){ dataInterface.deleteCollection(details) - .done(function(result){ + .done(function(result) { actions.resources.deleteCollection.completed(details, result); + if (typeof params.onComplete === 'function') { + params.onComplete(details, result); + } }) .fail(actions.resources.deleteCollection.failed); }); +actions.resources.deleteCollection.failed.listen(() => { + notify(t('Failed to delete collection.'), 'error'); +}); actions.resources.updateCollection.listen(function(uid, values){ dataInterface.patchCollection(uid, values) @@ -622,46 +547,36 @@ actions.resources.updateCollection.listen(function(uid, values){ }); }); -actions.resources.cloneAsset.listen(function(details, opts={}){ +actions.resources.cloneAsset.listen(function(details, params={}){ dataInterface.cloneAsset(details) - .done(function(...args){ - actions.resources.createAsset.completed(...args); - actions.resources.cloneAsset.completed(...args); - if (opts.onComplete) { - opts.onComplete(...args); + .done((asset) => { + actions.resources.cloneAsset.completed(asset); + if (typeof params.onComplete === 'function') { + params.onComplete(asset); } }) .fail(actions.resources.cloneAsset.failed); }); +actions.resources.cloneAsset.failed.listen(() => { + notify(t('Could not create project!'), 'error'); +}); -actions.search.assets.listen(function(queryString){ - dataInterface.searchAssets(queryString) - .done(function(...args){ - actions.search.assets.completed.apply(this, [queryString, ...args]); +actions.search.assets.listen(function(searchData, params={}){ + dataInterface.searchAssets(searchData) + .done(function(response){ + actions.search.assets.completed(searchData, response); + if (typeof params.onComplete === 'function') { + params.onComplete(searchData, response); + } }) - .fail(function(...args){ - actions.search.assets.failed.apply(this, [queryString, ...args]); + .fail(function(response){ + actions.search.assets.failed(searchData, response); + if (typeof params.onFailed === 'function') { + params.onFailed(searchData, response); + } }); }); -actions.search.libraryDefaultQuery.listen(function(){ - dataInterface.libraryDefaultSearch() - .done(actions.search.libraryDefaultQuery.completed) - .fail(actions.search.libraryDefaultQuery.failed); -}); - -actions.search.assetsWithTags.listen(function(queryString){ - dataInterface.assetSearch(queryString) - .done(actions.search.assetsWithTags.completed) - .fail(actions.search.assetsWithTags.failed); -}); - -actions.search.tags.listen(function(queryString){ - dataInterface.searchTags(queryString) - .done(actions.search.searchTags.completed) - .fail(actions.search.searchTags.failed); -}); - actions.permissions.assignPerm.listen(function(creds){ dataInterface.assignPerm(creds) .done(actions.permissions.assignPerm.completed) @@ -670,6 +585,9 @@ actions.permissions.assignPerm.listen(function(creds){ actions.permissions.assignPerm.completed.listen(function(val){ actions.resources.loadAsset({url: val.content_object}); }); +actions.permissions.assignPerm.failed.listen(function(){ + notify(t('failed to update permissions'), 'error'); +}); // copies permissions from one asset to other actions.permissions.copyPermissionsFrom.listen(function(sourceUid, targetUid) { @@ -695,6 +613,9 @@ actions.permissions.removePerm.listen(function(details){ actions.permissions.removePerm.completed.listen(function(uid){ actions.resources.loadAsset({id: uid}); }); +actions.permissions.removePerm.failed.listen(function(){ + notify(t('failed to remove permissions'), 'error'); +}); actions.permissions.setCollectionDiscoverability.listen(function(uid, discoverable){ dataInterface.patchCollection(uid, {discoverable_when_public: discoverable}) @@ -705,19 +626,6 @@ actions.permissions.setCollectionDiscoverability.completed.listen(function(val){ actions.resources.loadAsset({url: val.url}); }); -actions.auth.login.listen(function(creds){ - dataInterface.login(creds).done(function(resp1){ - dataInterface.selfProfile().done(function(data){ - if(data.username) { - actions.auth.login.loggedin(data); - } else { - actions.auth.login.passwordfail(resp1); - } - }).fail(actions.auth.login.failed); - }) - .fail(actions.auth.login.failed); -}); - // reload so a new csrf token is issued actions.auth.logout.completed.listen(function(){ window.setTimeout(function(){ @@ -781,35 +689,20 @@ actions.resources.loadAsset.listen(function(params){ } dataInterface[dispatchMethodName](params) - .done(actions.resources.loadAsset.completed) - .fail(actions.resources.loadAsset.failed); + .done(actions.resources.loadAsset.completed) + .fail(actions.resources.loadAsset.failed); }); actions.resources.loadAssetContent.listen(function(params){ dataInterface.getAssetContent(params) - .done(function(data, ...args) { - // data.sheeted = new Sheeted([['survey', 'choices', 'settings'], data.data]) - actions.resources.loadAssetContent.completed(data, ...args); - }) - .fail(actions.resources.loadAssetContent.failed); -}); - -actions.resources.listAssets.listen(function(){ - dataInterface.listAllAssets() - .done(actions.resources.listAssets.completed) - .fail(actions.resources.listAssets.failed); -}); - -actions.resources.listSurveys.listen(function(){ - dataInterface.listSurveys() - .done(actions.resources.listAssets.completed) - .fail(actions.resources.listAssets.failed); + .done(actions.resources.loadAssetContent.completed) + .fail(actions.resources.loadAssetContent.failed); }); actions.resources.listCollections.listen(function(){ dataInterface.listCollections() - .done(actions.resources.listCollections.completed) - .fail(actions.resources.listCollections.failed); + .done(actions.resources.listCollections.completed) + .fail(actions.resources.listCollections.failed); }); actions.resources.updateSubmissionValidationStatus.listen(function(uid, sid, data){ @@ -821,4 +714,151 @@ actions.resources.updateSubmissionValidationStatus.listen(function(uid, sid, dat }); }); +actions.hooks.getAll.listen((assetUid, callbacks = {}) => { + dataInterface.getHooks(assetUid) + .done((...args) => { + actions.hooks.getAll.completed(...args); + if (typeof callbacks.onComplete === 'function') { + callbacks.onComplete(...args); + } + }) + .fail((...args) => { + actions.hooks.getAll.failed(...args); + if (typeof callbacks.onFail === 'function') { + callbacks.onFail(...args); + } + }); +}); + +actions.hooks.add.listen((assetUid, data, callbacks = {}) => { + dataInterface.addExternalService(assetUid, data) + .done((...args) => { + actions.hooks.getAll(assetUid); + actions.hooks.add.completed(...args); + if (typeof callbacks.onComplete === 'function') { + callbacks.onComplete(...args); + } + }) + .fail((...args) => { + actions.hooks.add.failed(...args); + if (typeof callbacks.onFail === 'function') { + callbacks.onFail(...args); + } + }); +}); +actions.hooks.add.completed.listen((response) => { + notify(t('REST Service added successfully')); +}); +actions.hooks.add.failed.listen((response) => { + notify(t('Failed adding REST Service'), 'error'); +}); + +actions.hooks.update.listen((assetUid, hookUid, data, callbacks = {}) => { + dataInterface.updateExternalService(assetUid, hookUid, data) + .done((...args) => { + actions.hooks.getAll(assetUid); + actions.hooks.update.completed(...args); + if (typeof callbacks.onComplete === 'function') { + callbacks.onComplete(...args); + } + }) + .fail((...args) => { + actions.hooks.update.failed(...args); + if (typeof callbacks.onFail === 'function') { + callbacks.onFail(...args); + } + }); +}); +actions.hooks.update.completed.listen((response) => { + notify(t('REST Service updated successfully')); +}); +actions.hooks.update.failed.listen((response) => { + notify(t('Failed saving REST Service'), 'error'); +}); + +actions.hooks.delete.listen((assetUid, hookUid, callbacks = {}) => { + dataInterface.deleteExternalService(assetUid, hookUid) + .done((...args) => { + actions.hooks.getAll(assetUid); + actions.hooks.delete.completed(...args); + if (typeof callbacks.onComplete === 'function') { + callbacks.onComplete(...args); + } + }) + .fail((...args) => { + actions.hooks.delete.failed(...args); + if (typeof callbacks.onFail === 'function') { + callbacks.onFail(...args); + } + }); +}); +actions.hooks.delete.completed.listen((response) => { + notify(t('REST Service deleted permanently')); +}); +actions.hooks.delete.failed.listen((response) => { + notify(t('Could not delete REST Service'), 'error'); +}); + +actions.hooks.getLogs.listen((assetUid, hookUid, callbacks = {}) => { + dataInterface.getHookLogs(assetUid, hookUid) + .done((...args) => { + actions.hooks.getLogs.completed(...args); + if (typeof callbacks.onComplete === 'function') { + callbacks.onComplete(...args); + } + }) + .fail((...args) => { + actions.hooks.getLogs.failed(...args); + if (typeof callbacks.onFail === 'function') { + callbacks.onFail(...args); + } + }); +}); + +actions.hooks.retryLog.listen((assetUid, hookUid, lid, callbacks = {}) => { + dataInterface.retryExternalServiceLog(assetUid, hookUid, lid) + .done((...args) => { + actions.hooks.getLogs(assetUid, hookUid); + actions.hooks.retryLog.completed(...args); + if (typeof callbacks.onComplete === 'function') { + callbacks.onComplete(...args); + } + }) + .fail((...args) => { + actions.hooks.retryLog.failed(...args); + if (typeof callbacks.onFail === 'function') { + callbacks.onFail(...args); + } + }); +}); +actions.hooks.retryLog.completed.listen((response) => { + notify(t('Submission retry requested successfully')); +}); +actions.hooks.retryLog.failed.listen((response) => { + notify(t('Submission retry request failed'), 'error'); +}); + +actions.hooks.retryLogs.listen((assetUid, hookUid, callbacks = {}) => { + dataInterface.retryExternalServiceLogs(assetUid, hookUid) + .done((...args) => { + actions.hooks.retryLogs.completed(...args); + if (typeof callbacks.onComplete === 'function') { + callbacks.onComplete(...args); + } + }) + .fail((...args) => { + actions.hooks.getLogs(assetUid, hookUid); + actions.hooks.retryLogs.failed(...args); + if (typeof callbacks.onFail === 'function') { + callbacks.onFail(...args); + } + }); +}); +actions.hooks.retryLogs.completed.listen((response) => { + notify(t(response.detail), 'warning'); +}); +actions.hooks.retryLogs.failed.listen((response) => { + notify(t('Retrying all submissions failed'), 'error'); +}); + module.exports = actions; diff --git a/jsapp/js/app.es6 b/jsapp/js/app.es6 index 7436a28b2f..3777df3bb0 100644 --- a/jsapp/js/app.es6 +++ b/jsapp/js/app.es6 @@ -102,9 +102,6 @@ class App extends React.Component { case 'EDGE': document.body.classList.toggle('hide-edge') break - case 'CLOSE_MODAL': - stores.pageState.hideModal() - break } } getChildContext() { @@ -121,23 +118,21 @@ class App extends React.Component { global isolate> - { !this.isFormBuilder() && !this.state.pageState.headerHidden && + { !this.isFormBuilder() &&
} { this.state.pageState.modal && } - { !this.isFormBuilder() && !this.state.pageState.headerHidden && + { !this.isFormBuilder() && } - { !this.isFormBuilder() && !this.state.pageState.drawerHidden && + { !this.isFormBuilder() && } @@ -322,8 +317,11 @@ export var routes = ( - + + + + {/* used to force refresh form screens */} diff --git a/jsapp/js/assetParserUtils.es6 b/jsapp/js/assetParserUtils.es6 index b1abba60d1..f59e6e3fa6 100644 --- a/jsapp/js/assetParserUtils.es6 +++ b/jsapp/js/assetParserUtils.es6 @@ -5,7 +5,7 @@ import { function parseTags (asset) { return { - tags: asset.tag_string.split(',').filter((tg) => { return tg.length > 1; }) + tags: asset.tag_string.split(',').filter((tg) => { return tg.length !== 0; }) }; } diff --git a/jsapp/js/bem.es6 b/jsapp/js/bem.es6 index 89a36de88a..8af7056dc0 100644 --- a/jsapp/js/bem.es6 +++ b/jsapp/js/bem.es6 @@ -11,11 +11,15 @@ bem.Loading = BEM('loading'); bem.Loading__inner = bem.Loading.__('inner'); bem.Loading__msg = bem.Loading.__('msg'); +bem.EmptyContent = BEM('empty-content', '
'); +bem.EmptyContent__icon = bem.EmptyContent.__('icon', ''); +bem.EmptyContent__title = bem.EmptyContent.__('title', '

'); +bem.EmptyContent__message = bem.EmptyContent.__('message', '

'); +bem.EmptyContent__button = bem.EmptyContent.__('button', ' + + ); + })} + + + + ) + } + + /* + * handle fields + */ + + onSubsetFieldsChange(evt) { + this.setState({subsetFields: evt}); + } + + renderFieldsSelector() { + const inputProps = { + placeholder: t('Add field(s)'), + id: 'subset-fields-input' + }; + + return ( + + + + + + ) + } + + /* + * rendering + */ + + render() { + const isEditingExistingHook = Boolean(this.state.hookUid); + + if (this.state.isLoadingHook) { + return ( + + + + {t('loading...')} + + + ); + } else { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + - - @@ -270,7 +269,7 @@ export class AccountSettings extends React.Component { - + @@ -286,7 +288,7 @@ export class AccountSettings extends React.Component { - + - + @@ -357,6 +365,9 @@ export class AccountSettings extends React.Component { value={this.state.defaultLanguage} options={this.state.languageChoices} onChange={this.defaultLanguageChange} + className='kobo-select' + classNamePrefix='kobo-select' + menuPlacement='auto' /> diff --git a/jsapp/js/components/assetrow.es6 b/jsapp/js/components/assetrow.es6 index d8c0e2b53b..d74291f6e3 100644 --- a/jsapp/js/components/assetrow.es6 +++ b/jsapp/js/components/assetrow.es6 @@ -10,38 +10,23 @@ import stores from '../stores'; import mixins from '../mixins'; import {dataInterface} from '../dataInterface'; import {ASSET_TYPES} from '../constants'; - import TagInput from '../components/tagInput'; - import { formatTime, - anonUsername, - t, - assign, - validFileTypes + t } from '../utils'; class AssetRow extends React.Component { constructor(props){ super(props); this.state = { - tags: this.props.tags, + isTagsInputVisible: false, clearPopover: false, popoverVisible: false }; + this.escFunction = this.escFunction.bind(this); autoBind(this); } - // clickAsset (evt) { - // // this click was not intended for a button - // evt.nativeEvent.preventDefault(); - // evt.nativeEvent.stopImmediatePropagation(); - // evt.preventDefault(); - - // // if no asset is selected, then this asset - // // otherwise, toggle selection (unselect if already selected) - // // let forceSelect = (stores.selectedAsset.uid === false); - // // stores.selectedAsset.toggleSelect(this.props.uid, forceSelect); - // } clickAssetButton (evt) { var clickedActionIcon = $(evt.target).closest('[data-action]').get(0); if (clickedActionIcon) { @@ -51,11 +36,19 @@ class AssetRow extends React.Component { this.props.onActionButtonClick(action, this.props.uid, name); } } - clickTagsToggle (evt) { - var tagsToggle = !this.state.displayTags; - this.setState({ - displayTags: tagsToggle, - }); + clickTagsToggle () { + const isTagsInputVisible = !this.state.isTagsInputVisible; + if (isTagsInputVisible) { + document.addEventListener('keydown', this.escFunction); + } else { + document.removeEventListener('keydown', this.escFunction); + } + this.setState({isTagsInputVisible: isTagsInputVisible}); + } + escFunction (evt) { + if (evt.keyCode === 27 && this.state.isTagsInputVisible) { + this.clickTagsToggle(); + } } componentDidMount () { this.prepParentCollection(); @@ -98,15 +91,16 @@ class AssetRow extends React.Component { var _rc = this.props.summary && this.props.summary.row_count || 0; var hrefTo = `/forms/${this.props.uid}`, - linkClassName = this.props.name ? 'asset-row__celllink--titled' : 'asset-row__celllink--untitled', tags = this.props.tags || [], ownedCollections = [], parent = undefined; - var isDeployable = this.props.asset_type && this.props.asset_type === 'survey' && this.props.deployed_version_id === null; + var isDeployable = this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && this.props.deployed_version_id === null; const userCanEdit = this.userCan('change_asset', this.props); + const assetName = this.props.name || this.props.firstQuestionLabel; + if (this.props.has_deployment && this.props.deployment__submission_count && this.userCan('view_submissions', this.props)) { hrefTo = `/forms/${this.props.uid}/summary`; @@ -131,7 +125,7 @@ class AssetRow extends React.Component { return ( + + {/* "title" column */} {_rc} } - - - - - - { this.props.asset_type && this.props.asset_type === 'survey' && + + + + { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && this.props.settings.description && {this.props.settings.description} @@ -195,19 +189,19 @@ class AssetRow extends React.Component { key={'userlink'} className={[ 'mdl-cell', - this.props.asset_type == 'survey' ? 'mdl-cell--2-col mdl-cell--1-col-tablet mdl-cell--hide-phone' : 'mdl-cell--2-col mdl-cell--2-col-tablet mdl-cell--1-col-phone' + this.props.asset_type == ASSET_TYPES.survey.id ? 'mdl-cell--2-col mdl-cell--1-col-tablet mdl-cell--hide-phone' : 'mdl-cell--2-col mdl-cell--2-col-tablet mdl-cell--1-col-phone' ]} > - { this.props.asset_type == 'survey' && + { this.props.asset_type == ASSET_TYPES.survey.id && { selfowned ? ' ' : this.props.owner__username } } - { this.props.asset_type != 'survey' && + { this.props.asset_type != ASSET_TYPES.survey.id && {selfowned ? t('me') : this.props.owner__username} } {/* "date created" column for surveys */} - { this.props.asset_type == 'survey' && + { this.props.asset_type == ASSET_TYPES.survey.id && {/* "submission count" column for surveys */} - { this.props.asset_type == 'survey' && + { this.props.asset_type == ASSET_TYPES.survey.id && - { this.state.displayTags && + { this.state.isTagsInputVisible && @@ -306,7 +300,7 @@ class AssetRow extends React.Component { data-action={'cloneAsSurvey'} data-tip={t('Create project')} data-asset-type={this.props.kind} - data-asset-name={this.props.name} + data-asset-name={assetName} data-disabled={false} > @@ -335,7 +329,7 @@ class AssetRow extends React.Component { clearPopover={this.state.clearPopover} popoverSetVisible={this.popoverSetVisible} > - { this.props.asset_type && this.props.asset_type === 'survey' && userCanEdit && isDeployable && + { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && userCanEdit && isDeployable && } - { this.props.asset_type && this.props.asset_type === 'survey' && this.props.has_deployment && !this.props.deployment__active && userCanEdit && + { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && this.props.has_deployment && !this.props.deployment__active && userCanEdit && } - { this.props.asset_type && this.props.asset_type === 'survey' && userCanEdit && + { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && userCanEdit && - {t('Replace project')} + {t('Replace form')} + + } + { userCanEdit && + + + {t('Manage Translations')} } {this.props.downloads.map((dl)=>{ @@ -374,12 +377,12 @@ class AssetRow extends React.Component { ); })} - { this.props.asset_type && this.props.asset_type != 'survey' && ownedCollections.length > 0 && + { this.props.asset_type && this.props.asset_type != ASSET_TYPES.survey.id && ownedCollections.length > 0 && {t('Move to')} } - { this.props.asset_type && this.props.asset_type != 'survey' && ownedCollections.length > 0 && + { this.props.asset_type && this.props.asset_type != ASSET_TYPES.survey.id && ownedCollections.length > 0 && {ownedCollections.map((col)=>{ return ( @@ -400,7 +403,7 @@ class AssetRow extends React.Component { })} } - { this.props.asset_type && this.props.asset_type === 'survey' && this.props.has_deployment && this.props.deployment__active && userCanEdit && + { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && this.props.has_deployment && this.props.deployment__active && userCanEdit && } - { this.props.asset_type && this.props.asset_type === 'survey' && userCanEdit && + { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && userCanEdit && {t('Create template')} @@ -423,10 +426,11 @@ class AssetRow extends React.Component { } {userCanEdit && + m={'delete'} + data-action={'delete'} + data-asset-type={this.props.kind} + data-asset-name={assetName} + > {t('Delete')} diff --git a/jsapp/js/components/checkbox.es6 b/jsapp/js/components/checkbox.es6 new file mode 100644 index 0000000000..32209e0747 --- /dev/null +++ b/jsapp/js/components/checkbox.es6 @@ -0,0 +1,47 @@ +import React from 'react'; +import autoBind from 'react-autobind'; +import bem from '../bem'; + +/* +Properties: +- checked +- onChange : required +- label +*/ +class Checkbox extends React.Component { + constructor(props){ + if (typeof props.onChange !== 'function') { + throw new Error('onChange callback missing!') + } + super(props); + autoBind(this); + } + + onChange(evt) { + this.props.onChange(evt.currentTarget.checked); + } + + render() { + return ( + + + + + {this.props.label && + + {this.props.label} + + } + + + ) + } +} + +export default Checkbox; diff --git a/jsapp/js/components/drawer.es6 b/jsapp/js/components/drawer.es6 index a729b9d3bf..4ec316675a 100644 --- a/jsapp/js/components/drawer.es6 +++ b/jsapp/js/components/drawer.es6 @@ -4,7 +4,6 @@ import reactMixin from 'react-mixin'; import autoBind from 'react-autobind'; import Reflux from 'reflux'; import { Link } from 'react-router'; -import Dropzone from 'react-dropzone'; import Select from 'react-select'; import {dataInterface} from '../dataInterface'; diff --git a/jsapp/js/components/formEditors.es6 b/jsapp/js/components/formEditors.es6 index ba40b1dca4..e8e8a6e58d 100644 --- a/jsapp/js/components/formEditors.es6 +++ b/jsapp/js/components/formEditors.es6 @@ -7,6 +7,7 @@ import Reflux from 'reflux'; import alertify from 'alertifyjs'; import editableFormMixin from '../editorMixins/editableForm'; import moment from 'moment'; +import Checkbox from './checkbox'; import bem from '../bem'; import DocumentTitle from 'react-document-title'; import mixins from '../mixins'; @@ -78,16 +79,21 @@ export class ProjectDownloads extends React.Component { let url = this.props.asset.deployment__data_download_links[ this.state.type ]; - if (this.state.type == 'xls' || this.state.type == 'csv') { + if (['xls', 'csv', 'spss_labels'].includes(this.state.type)) { url = `${dataInterface.rootUrl}/exports/`; // TODO: have the backend pass the URL in the asset let postData = { source: this.props.asset.url, type: this.state.type, - lang: this.state.lang, - hierarchy_in_labels: this.state.hierInLabels, - group_sep: this.state.groupSep, fields_from_all_versions: this.state.fieldsFromAllVersions }; + if (['xls', 'csv'].includes(this.state.type)) { + // Only send extra parameters when necessary + Object.assign(postData, { + lang: this.state.lang, + hierarchy_in_labels: this.state.hierInLabels, + group_sep: this.state.groupSep + }); + } $.ajax({ method: 'POST', url: url, @@ -159,6 +165,31 @@ export class ProjectDownloads extends React.Component { dataInterface.getAssetExports(this.props.asset.uid).done((data)=>{ if (data.count > 0) { data.results.reverse(); + data.results.map(result => { + if (result.data.type === 'spss_labels') { + // Some old SPSS exports may have a meaningless `lang` attribute -- + // disregard it + result.data.langDescription = ''; + return; + } + switch(result.data.lang) { + case '_default': + case null: // The value of `formpack.constants.UNTRANSLATED`, + // which shouldn't be revealed here, but just in case... + result.data.langDescription = t('Default'); + break; + case '_xml': + case false: // `formpack.constants.UNSPECIFIED_TRANSLATION` + // Exports previously used `xml` (no underscore) for this, which + // works so long as the form has no language called `xml`. In + // reality, we shouldn't bank on that: + // https://en.wikipedia.org/wiki/Malaysian_Sign_Language + result.data.langDescription = t('XML'); + break; + default: + result.data.langDescription = result.data.lang; + } + }); this.setState({exports: data.results}); // Start a polling Interval if there is at least one export is not yet complete @@ -226,34 +257,33 @@ export class ProjectDownloads extends React.Component { - , this.state.type == 'xls' || this.state.type == 'csv' ? [ - - - - , - - - - , - this.state.hierInLabels ? + , ['xls', 'csv', 'spss_labels'].includes(this.state.type) ? [ + ['xls', 'csv'].includes(this.state.type) ? [ + + + + , + + + , + - : null, + ] : null, dvcount > 1 ? - - : null ] : null @@ -312,16 +340,19 @@ export class ProjectDownloads extends React.Component { - {item.data.type} + {item.data.type == 'spss_labels' ? 'spss' : item.data.type} {formatTime(item.date_created)} - {item.data.lang === '_default' ? t('Default') : item.data.lang} + {item.data.langDescription} - {item.data.hierarchy_in_labels === 'false' ? t('No') : t('Yes')} + { + // When not present, assume the default of "No" + item.data.hierarchy_in_labels === 'true' ? t('Yes') : t('No') + } { diff --git a/jsapp/js/components/formLanding.es6 b/jsapp/js/components/formLanding.es6 index 63c93a0403..a1bbd15122 100644 --- a/jsapp/js/components/formLanding.es6 +++ b/jsapp/js/components/formLanding.es6 @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import reactMixin from 'react-mixin'; import autoBind from 'react-autobind'; import Reflux from 'reflux'; -import Map from 'es6-map'; import _ from 'underscore'; import { Link } from 'react-router'; import actions from '../actions'; @@ -41,11 +40,14 @@ export class FormLanding extends React.Component { assetid: this.state.uid }); } + callUnarchiveAsset(evt) { + this.unarchiveAsset(); + } renderFormInfo (userCanEdit) { var dvcount = this.state.deployed_versions.count; var undeployedVersion = undefined; - if (this.state.deployed_version_id !== this.state.version_id && this.state.deployment__active) { + if (!this.isCurrentVersionDeployed()) { undeployedVersion = `(${t('undeployed')})`; dvcount = dvcount + 1; } @@ -87,7 +89,7 @@ export class FormLanding extends React.Component { {userCanEdit && this.state.has_deployment && !this.state.deployment__active && + onClick={this.callUnarchiveAsset}> {t('unarchive')} } @@ -95,7 +97,7 @@ export class FormLanding extends React.Component { ); } - sharingModal (evt) { + showSharingModal (evt) { evt.preventDefault(); stores.pageState.showModal({ type: MODAL_TYPES.SHARING, @@ -109,6 +111,25 @@ export class FormLanding extends React.Component { asset: this.state }); } + isCurrentVersionDeployed() { + if ( + this.state.deployment__active && + this.state.deployed_versions.count > 0 && + this.state.deployed_version_id + ) { + const deployed_version = this.state.deployed_versions.results.find( + (version) => {return version.uid === this.state.deployed_version_id} + ) + return deployed_version.content_hash === this.state.version__content_hash; + } + return false; + } + isFormRedeploymentNeeded() { + return !this.isCurrentVersionDeployed() && this.userCan('change_asset', this.state); + } + hasLanguagesDefined(translations) { + return translations && (translations.length > 1 || translations[0] !== null); + } showLanguagesModal (evt) { evt.preventDefault(); stores.pageState.showModal({ @@ -344,7 +365,7 @@ export class FormLanding extends React.Component { {userCanEdit && @@ -373,7 +394,7 @@ export class FormLanding extends React.Component { {userCanEdit && - + {t('Share this project')} @@ -392,14 +413,11 @@ export class FormLanding extends React.Component { {t('Create template')} + {userCanEdit && this.state.content.survey.length > 0 && - {(!translations || translations.length < 2) ? - t('Add Translations') - : - t('Manage Translations') - } + {t('Manage Translations')} } @@ -408,23 +426,26 @@ export class FormLanding extends React.Component { } renderLanguages (canEdit) { let translations = this.state.content.translations; - if (!translations || translations.length < 2) - return false; return ( {t('Languages:')}   -

    - {translations.map((langString)=>{ - return ( -
  • - {langString || t('Unnamed language')} -
  • - ); - })} -
+ {!this.hasLanguagesDefined(translations) && + t('This project has no languages defined yet') + } + {this.hasLanguagesDefined(translations) && +
    + {translations.map((langString, n)=>{ + return ( +
  • + {langString || t('Unnamed language')} +
  • + ); + })} +
+ } {canEdit && @@ -471,8 +492,7 @@ export class FormLanding extends React.Component { - {userCanEdit && this.state.deployed_versions.count > 0 && - this.state.deployed_version_id != this.state.version_id && this.state.deployment__active && + {this.isFormRedeploymentNeeded() && {t('If you want to make these changes public, you must deploy this form.')} diff --git a/jsapp/js/components/formSubScreens.es6 b/jsapp/js/components/formSubScreens.es6 index b46886d9db..6e5a869631 100644 --- a/jsapp/js/components/formSubScreens.es6 +++ b/jsapp/js/components/formSubScreens.es6 @@ -22,6 +22,7 @@ import {ProjectDownloads} from '../components/formEditors'; import {PROJECT_SETTINGS_CONTEXTS} from '../constants'; import FormMap from '../components/map'; +import RESTServices from '../components/RESTServices'; import { assign, @@ -87,9 +88,6 @@ export class FormSubScreens extends React.Component { case `/forms/${this.state.uid}/data/map/${this.props.params.viewby}`: return ; break; - // case `/forms/${this.state.uid}/settings/kobocat`: - // iframeUrl = deployment__identifier+'/form_settings'; - // break; case `/forms/${this.state.uid}/data/downloads`: return this.renderProjectDownloads(); break; @@ -98,6 +96,21 @@ export class FormSubScreens extends React.Component { iframeUrl = deployment__identifier+'/form_settings'; return this.renderSettingsEditor(iframeUrl); break; + case `/forms/${this.state.uid}/settings/media`: + iframeUrl = deployment__identifier+'/form_settings'; + break; + case `/forms/${this.state.uid}/settings/sharing`: + return this.renderSharing(); + break; + case `/forms/${this.state.uid}/settings/rest`: + return ; + break; + case `/forms/${this.state.uid}/settings/rest/${this.props.params.hookUid}`: + return ; + break; + case `/forms/${this.state.uid}/settings/kobocat`: + iframeUrl = deployment__identifier+'/form_settings'; + break; case `/forms/${this.state.uid}/reset`: return this.renderReset(); break; @@ -138,6 +151,13 @@ export class FormSubScreens extends React.Component { ); } + renderSharing() { + return ( + + + + ); + } renderReset() { return ( diff --git a/jsapp/js/components/formSummary.es6 b/jsapp/js/components/formSummary.es6 index 3f2745c0d9..ca329839c7 100644 --- a/jsapp/js/components/formSummary.es6 +++ b/jsapp/js/components/formSummary.es6 @@ -213,7 +213,7 @@ class FormSummary extends React.Component { {this.userCan('change_asset', this.state) && - {t('Share form')} + {t('Share project')} } diff --git a/jsapp/js/components/formViewTabs.es6 b/jsapp/js/components/formViewTabs.es6 index 6331b9535f..81d65db498 100644 --- a/jsapp/js/components/formViewTabs.es6 +++ b/jsapp/js/components/formViewTabs.es6 @@ -109,13 +109,15 @@ class FormViewTabs extends Reflux.Component { ]; } - // if (this.state.asset && this.state.asset.deployment__active && this.isActiveRoute(`/forms/${this.state.assetid}/settings`)) { - // sideTabs = [ - // {label: t('General settings'), icon: 'k-icon-information', path: `/forms/${this.state.assetid}/settings`}, - // {label: t('Sharing'), icon: 'k-icon-share', path: `/forms/${this.state.assetid}/settings/sharing`}, - // {label: t('Kobocat settings'), icon: 'k-icon-projects', path: `/forms/${this.state.assetid}/settings/kobocat`} - // ]; - // } + if (this.state.asset && this.state.asset.deployment__active && this.isActiveRoute(`/forms/${this.state.assetid}/settings`)) { + sideTabs = [ + {label: t('General'), icon: 'k-icon-settings', path: `/forms/${this.state.assetid}/settings`}, + {label: t('Media'), icon: 'k-icon-photo-gallery', path: `/forms/${this.state.assetid}/settings/media`}, + {label: t('Sharing'), icon: 'k-icon-share', path: `/forms/${this.state.assetid}/settings/sharing`}, + {label: t('REST Services'), icon: 'k-icon-data-sync', path: `/forms/${this.state.assetid}/settings/rest`}, + {label: t('Kobocat (legacy)'), icon: 'k-icon-settings', path: `/forms/${this.state.assetid}/settings/kobocat`, className: 'is-edge'}, + ]; + } if (sideTabs.length > 0) { return ( diff --git a/jsapp/js/components/header.es6 b/jsapp/js/components/header.es6 index 1e487d2cdb..2d3b719cd8 100644 --- a/jsapp/js/components/header.es6 +++ b/jsapp/js/components/header.es6 @@ -3,10 +3,8 @@ import PropTypes from 'prop-types'; import reactMixin from 'react-mixin'; import autoBind from 'react-autobind'; import { hashHistory } from 'react-router'; -import Select from 'react-select'; import alertify from 'alertifyjs'; import ui from '../ui'; - import stores from '../stores'; import Reflux from 'reflux'; import bem from '../bem'; @@ -21,30 +19,17 @@ import { stringToColor, } from '../utils'; import searches from '../searches'; - -import { - ListSearch, - ListTagFilter, -} from '../components/list'; +import {ListSearch} from '../components/list'; let typingTimer; -function langsToValues (langs) { - return langs.map(function(lang) { - return { - value: lang[0], - label: lang[1], - }; - }); -} - class MainHeader extends Reflux.Component { constructor(props){ super(props); this.state = assign({ - dataPopoverShowing: false, asset: false, currentLang: currentLang(), + isLanguageSelectorVisible: false, libraryFiltersContext: searches.getSearchContext('library', { filterParams: { assetType: 'asset_type:question OR asset_type:block OR asset_type:template', @@ -56,8 +41,7 @@ class MainHeader extends Reflux.Component { assetType: 'asset_type:survey', }, filterTags: 'asset_type:survey', - }), - _langIndex: 0 + }) }, stores.pageState.state); this.stores = [ stores.session, @@ -69,18 +53,21 @@ class MainHeader extends Reflux.Component { document.body.classList.add('hide-edge'); this.listenTo(stores.asset, this.assetLoad); } + componentWillUpdate(newProps) { + if (this.props.assetid !== newProps.assetid) { + this.setState({asset: false}); + } + } assetLoad(data) { - var assetid = this.props.assetid; - var asset = data[assetid]; - - this.setState(assign({ - asset: asset - } - )); + const asset = data[this.props.assetid]; + this.setState(assign({asset: asset})); } logout () { actions.auth.logout(); } + toggleLanguageSelector() { + this.setState({isLanguageSelectorVisible: !this.state.isLanguageSelectorVisible}) + } accountSettings () { // verifyLogin also refreshes stored profile data actions.auth.verifyLogin.triggerAsync().then(() => { @@ -156,13 +143,16 @@ class MainHeader extends Reflux.Component { } - + {t('Language')} -
    - {langs.map(this.renderLangItem)} -
+ + {this.state.isLanguageSelectorVisible && +
    + {langs.map(this.renderLangItem)} +
+ }
@@ -200,6 +190,23 @@ class MainHeader extends Reflux.Component { toggleFixedDrawer() { stores.pageState.toggleFixedDrawer(); } + updateAssetTitle() { + if (!this.state.asset.name.trim()) { + alertify.error(t('Please enter a title for your project')); + return false; + } else { + actions.resources.updateAsset( + this.state.asset.uid, + { + name: this.state.asset.name, + settings: JSON.stringify({ + description: this.state.asset.settings.description + }) + } + ); + return true; + } + } assetTitleChange (e) { var asset = this.state.asset; if (e.target.name == 'title') @@ -212,29 +219,30 @@ class MainHeader extends Reflux.Component { }); clearTimeout(typingTimer); - - typingTimer = setTimeout(() => { - if (!this.state.asset.name.trim()) { - alertify.error(t('Please enter a title for your project')); - } else { - actions.resources.updateAsset( - this.state.asset.uid, - { - name: this.state.asset.name, - settings: JSON.stringify({ - description: this.state.asset.settings.description, - }), - } - ); + typingTimer = setTimeout(this.updateAssetTitle.bind(this), 1500); + } + assetTitleKeyDown(evt) { + if (evt.key === 'Enter') { + clearTimeout(typingTimer); + if (this.updateAssetTitle()) { + evt.currentTarget.blur(); } - }, 1500); - + } } render () { var userCanEditAsset = false; if (this.state.asset) userCanEditAsset = this.userCan('change_asset', this.state.asset); + const formTitleNameMods = []; + if ( + this.state.asset && + typeof this.state.asset.name === 'string' && + this.state.asset.name.length > 125 + ) { + formTitleNameMods.push('long'); + } + return (
@@ -263,13 +271,15 @@ class MainHeader extends Reflux.Component { : } - - + { this.state.asset.has_deployment && diff --git a/jsapp/js/components/librarySidebar.es6 b/jsapp/js/components/librarySidebar.es6 index 8de03eed66..cf45e39d88 100644 --- a/jsapp/js/components/librarySidebar.es6 +++ b/jsapp/js/components/librarySidebar.es6 @@ -139,17 +139,17 @@ class LibrarySidebar extends Reflux.Component { message: t('are you sure you want to delete this collection? this action is not reversible'), labels: {ok: t('Delete'), cancel: t('Cancel')}, onok: (evt, val) => { - dataInterface.deleteCollection({uid: collectionUid}).then((data)=> { - this.quietUpdateStore({ - parentUid: false, - parentName: false, - allPublic: false - }); - this.searchValue(); - this.queryCollections(); - dialog.destroy(); - }).fail((jqxhr)=> { - alertify.error(t('Failed to delete collection.')); + actions.resources.deleteCollection({uid: collectionUid}, { + onComplete: (data) => { + this.quietUpdateStore({ + parentUid: false, + parentName: false, + allPublic: false + }); + this.searchValue(); + this.queryCollections(); + dialog.destroy(); + } }); }, oncancel: () => { @@ -159,8 +159,8 @@ class LibrarySidebar extends Reflux.Component { dialog.set(opts).show(); } renameCollection (evt) { - var collectionUid = $(evt.currentTarget).data('collection-uid'); - var collectionName = $(evt.currentTarget).data('collection-name'); + var collectionUid = evt.currentTarget.dataset.collectionUid; + var collectionName = evt.currentTarget.dataset.collectionName; let dialog = alertify.dialog('prompt'); let opts = { @@ -210,6 +210,9 @@ class LibrarySidebar extends Reflux.Component { assetid: collectionUid }); } + isCollectionPublic(collection) { + return typeof getAnonymousUserPermission(collection.permissions) !== 'undefined'; + } setCollectionDiscoverability (discoverable, collection) { return (evt) => { evt.preventDefault(); @@ -302,7 +305,7 @@ class LibrarySidebar extends Reflux.Component { {this.state.sidebarCollections.map((collection)=>{ var iconClass = 'k-icon-folder'; - if (collection.discoverable_when_public) + if (collection.discoverable_when_public || this.isCollectionPublic(collection)) iconClass = 'k-icon-folder-public'; if (collection.access_type == 'shared') iconClass = 'k-icon-folder-shared'; diff --git a/jsapp/js/components/list.es6 b/jsapp/js/components/list.es6 index f6d2922e23..8d03e5d69e 100644 --- a/jsapp/js/components/list.es6 +++ b/jsapp/js/components/list.es6 @@ -4,7 +4,7 @@ import reactMixin from 'react-mixin'; import autoBind from 'react-autobind'; import Reflux from 'reflux'; import Select from 'react-select'; - +import Checkbox from './checkbox'; import ui from '../ui'; import bem from '../bem'; import actions from '../actions'; @@ -28,6 +28,9 @@ class ListSearch extends React.Component { } this.setState(searchStoreState); } + getValue() { + return this.refs['formlist-search'].getValue(); + } render () { return ( @@ -69,12 +72,10 @@ class ListTagFilter extends React.Component { if (searchStoreState.searchTags) { let tags = null; if (searchStoreState.searchTags.length !== 0) { - tags = searchStoreState.searchTags.map(function(tag){ - return tag.value; - }).join(','); + tags = searchStoreState.searchTags; } this.setState({ - selectedTag: tags + selectedTags: tags }); } } @@ -89,40 +90,29 @@ class ListTagFilter extends React.Component { value: tag.name.replace(/\s/g, '-'), }; }), - selectedTag: null + selectedTags: null }); } - onTagChange (tagString) { - this.searchTagsChange(tagString); + onTagsChange (tagsList) { + this.searchTagsChange(tagsList); } render () { - if (!this.state.tagsLoaded) { - return ( - - - {return t('Tags are loading...')}} placeholder={t('Search Tags')} - noResultsText={t('No results found')} + noOptionsMessage={() => {return t('No results found')}} options={this.state.availableTags} - onChange={this.onTagChange} - className={[this.props.hidden ? 'hidden' : null, 'Select--underlined'].join(' ')} - value={this.state.selectedTag} + onChange={this.onTagsChange} + className={[this.props.hidden ? 'hidden' : null, 'kobo-select'].join(' ')} + classNamePrefix='kobo-select' + value={this.state.selectedTags} + menuPlacement='auto' /> ); @@ -163,41 +153,38 @@ class ListCollectionFilter extends React.Component { value: collection.uid, }; }), - selectedCollection: '' + selectedCollection: false }); }); } - onCollectionChange (collectionUid) { - if (collectionUid) { - this.searchCollectionChange(collectionUid.value); + onCollectionChange (evt) { + if (evt) { + this.searchCollectionChange(evt.value); this.setState({ - selectedCollection: collectionUid.value + selectedCollection: evt }); } else { this.searchClear(); this.setState({ - selectedCollection: '' + selectedCollection: false }); } } render () { - if (!this.state.collectionsLoaded) { - return ( - - {t('Collections are loading...')} - - ); - } return ( - ); diff --git a/jsapp/js/components/map.es6 b/jsapp/js/components/map.es6 index 5278a0480f..7de39d4c69 100644 --- a/jsapp/js/components/map.es6 +++ b/jsapp/js/components/map.es6 @@ -78,7 +78,7 @@ export class FormMap extends React.Component { hasGeoPoint: hasGeoPoint, submissions: [], error: false, - showExpandedMap: false, + isFullscreen: false, showExpandedLegend: true, langIndex: 0, filteredByMarker: false, @@ -565,11 +565,8 @@ export class FormMap extends React.Component { let map = this.refreshMap(); this.requestData(map, this.props.viewby); } - toggleExpandedMap () { - stores.pageState.hideDrawerAndHeader(!this.state.showExpandedMap); - this.setState({ - showExpandedMap: !this.state.showExpandedMap, - }); + toggleFullscreen () { + this.setState({isFullscreen: !this.state.isFullscreen}); var map = this.state.map; setTimeout(function(){ map.invalidateSize()}, 300); @@ -680,12 +677,17 @@ export class FormMap extends React.Component { }); } + const formViewModifiers = ['map']; + if (this.state.isFullscreen) { + formViewModifiers.push('fullscreen'); + } + return ( - + + className={this.state.toggleFullscreen ? 'active': ''}> {stores.pageState.hideModal()}} + onClose={this.onModalClose} title={this.state.title} className={this.state.modalClass} > @@ -223,8 +251,17 @@ class Modal extends React.Component { getColumnLabel={this.props.params.getColumnLabel} overrideLabelsAndGroups={this.props.params.overrideLabelsAndGroups} /> } + { this.props.params.type == MODAL_TYPES.REST_SERVICES && + + } { this.props.params.type == MODAL_TYPES.FORM_LANGUAGES && - + } { this.props.params.type == MODAL_TYPES.FORM_TRANSLATIONS_TABLE && diff --git a/jsapp/js/components/modalForms/copyTeamPermissions.es6 b/jsapp/js/components/modalForms/copyTeamPermissions.es6 index 5d60eb48ec..e4dda11145 100644 --- a/jsapp/js/components/modalForms/copyTeamPermissions.es6 +++ b/jsapp/js/components/modalForms/copyTeamPermissions.es6 @@ -47,7 +47,18 @@ class CopyTeamPermissions extends React.Component { this.setState({ isCopyFormVisible: !this.state.isCopyFormVisible }); } - updateTeamPermissionsInput(asset) { + getSelectedProjectOption() { + if (this.state.sourceUid) { + return { + value: this.state.sourceUid, + label: this.state.sourceName + } + } else { + return false; + } + } + + onSelectedProjectChange(asset) { if (asset !== null) { this.setState({ sourceUid: asset.value, @@ -125,11 +136,14 @@ class CopyTeamPermissions extends React.Component { - {(this.props.context === PROJECT_SETTINGS_CONTEXTS.NEW || this.props.context === PROJECT_SETTINGS_CONTEXTS.REPLACE) && @@ -727,6 +872,48 @@ class ProjectSettings extends React.Component {