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/.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/Dockerfile b/Dockerfile index 5f7f3a4206..544fe46ff7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -127,6 +127,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/dependencies/pip/dev_requirements.txt b/dependencies/pip/dev_requirements.txt index ae074e11ed..f0904ef545 100644 --- a/dependencies/pip/dev_requirements.txt +++ b/dependencies/pip/dev_requirements.txt @@ -5,10 +5,10 @@ # pip-compile --output-file dependencies/pip/dev_requirements.txt dependencies/pip/dev_requirements.in # -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 +-e git+https://github.com/kobotoolbox/formpack.git@40110eeb001b1a581aad6836f746fedef8be5752#egg=formpack amqp==2.1.4 anyjson==0.3.3 +argparse==1.4.0 # via unittest2 asn1crypto==0.24.0 # via cryptography begins==0.9 billiard==3.5.0.2 @@ -21,9 +21,11 @@ cookies==2.2.1 # via responses cryptography==2.2.2 # via paramiko, pyopenssl cssselect==1.0.3 # via pyquery cyordereddict==1.0.0 +defusedxml==0.5.0 # via djangorestframework-xml dj-database-url==0.4.2 dj-static==0.0.6 django-braces==1.11.0 +django-celery-beat==1.1.1 django-constance[database]==2.2.0 django-debug-toolbar==1.6 django-extensions==1.7.6 @@ -44,11 +46,13 @@ django-taggit==0.22.0 django-toolbelt==0.0.1 django-webpack-loader==0.4.1 django==1.8.17 +djangorestframework-xml==1.3.0 djangorestframework==3.5.4 docutils==0.13.1 # via botocore, statistics drf-extensions==0.3.1 enum34==1.1.6 # via cryptography fabric==1.13.1 +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 @@ -59,9 +63,10 @@ jmespath==0.9.3 # via boto3, botocore jsonfield==1.0.3 jsonschema==2.6.0 kombu==4.0.2 +linecache2==1.0.0 # via traceback2 lxml==4.2.1 markdown==2.6.8 -mock==2.0.0 # via responses +mock==2.0.0 ndg-httpsclient==0.4.2 oauthlib==1.1.2 paramiko==2.1.1 # via fabric @@ -80,6 +85,7 @@ pytest==3.0.6 # via pytest-django python-dateutil==2.6.0 python-digest==1.7 pytz==2016.10 +pyxform==0.11.5 requests==2.13.0 responses==0.9.0 s3transfer==0.1.11 # via boto3 @@ -89,7 +95,9 @@ sqlparse==0.2.2 static3==0.7.0 statistics==1.0.3.5 tabulate==0.7.7 +traceback2==1.4.0 # via unittest2 unicodecsv==0.14.1 +unittest2==1.1.0 # via pyxform uwsgi==2.0.17 vine==1.1.3 # via amqp werkzeug==0.11.15 diff --git a/dependencies/pip/external_services.txt b/dependencies/pip/external_services.txt index d24472ec8a..968195aafc 100644 --- a/dependencies/pip/external_services.txt +++ b/dependencies/pip/external_services.txt @@ -5,26 +5,28 @@ # pip-compile --output-file dependencies/pip/external_services.txt dependencies/pip/external_services.in # -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@40110eeb001b1a581aad6836f746fedef8be5752#egg=formpack +amqp==2.3.2 anyjson==0.3.3 +argparse==1.4.0 # via unittest2 asn1crypto==0.24.0 # via cryptography begins==0.9 -billiard==3.3.0.23 +billiard==3.5.0.4 boto3==1.5.8 boto==2.40.0 botocore==1.8.22 # via boto3, s3transfer -celery==3.1.23 +celery==4.2.1 cffi==1.8.3 # via cryptography contextlib2==0.5.4 # via 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-celery-beat==1.1.1 django-constance[database]==2.2.0 django-debug-toolbar==1.4 django-extensions==1.6.7 @@ -45,10 +47,12 @@ django-taggit==0.22.0 django-toolbelt==0.0.1 django-webpack-loader==0.3.0 django==1.8.13 +djangorestframework-xml==1.3.0 djangorestframework==3.3.3 docutils==0.12 # 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 @@ -58,10 +62,11 @@ 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 +kombu==4.2.1 +linecache2==1.0.0 # via traceback2 lxml==4.2.1 markdown==2.6.6 -mock==2.0.0 # via responses +mock==2.0.0 ndg-httpsclient==0.4.2 newrelic==2.84.0.64 oauthlib==1.0.3 @@ -80,6 +85,7 @@ pytest==3.0.3 # via pytest-django python-dateutil==2.6.0 python-digest==1.7 pytz==2016.4 +pyxform==0.11.5 raven==5.32.0 requests==2.10.0 responses==0.9.0 @@ -90,10 +96,13 @@ sqlparse==0.1.19 static3==0.7.0 statistics==1.0.3.5 tabulate==0.7.5 +traceback2==1.4.0 # via unittest2 transifex-client==0.11 unicodecsv==0.14.1 +unittest2==1.1.0 # via pyxform urllib3==1.15.1 # via transifex-client uwsgi==2.0.17 +vine==1.1.4 # via amqp whitenoise==3.3.1 whoosh==2.7.4 xlrd==1.1.0 diff --git a/dependencies/pip/requirements.in b/dependencies/pip/requirements.in index 8905df2492..f23b119beb 100644 --- a/dependencies/pip/requirements.in +++ b/dependencies/pip/requirements.in @@ -1,11 +1,8 @@ # 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@40110eeb001b1a581aad6836f746fedef8be5752#egg=formpack # More up-to-date version of django-digest than PyPI seems to have. # Also, python-digest is an unlisted dependency thereof. @@ -24,10 +21,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 +44,20 @@ 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..cee18e5462 100644 --- a/dependencies/pip/requirements.txt +++ b/dependencies/pip/requirements.txt @@ -5,25 +5,27 @@ # pip-compile --output-file dependencies/pip/requirements.txt dependencies/pip/requirements.in # -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@40110eeb001b1a581aad6836f746fedef8be5752#egg=formpack +amqp==2.3.2 anyjson==0.3.3 +argparse==1.4.0 # via unittest2 asn1crypto==0.24.0 # via cryptography begins==0.9 -billiard==3.3.0.23 +billiard==3.5.0.4 boto3==1.5.8 boto==2.40.0 botocore==1.8.22 # via boto3, s3transfer -celery==3.1.23 +celery==4.2.1 cffi==1.8.3 # via cryptography 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-celery-beat==1.1.1 django-constance[database]==2.2.0 django-debug-toolbar==1.4 django-extensions==1.6.7 @@ -44,10 +46,12 @@ django-taggit==0.22.0 django-toolbelt==0.0.1 django-webpack-loader==0.3.0 django==1.8.13 +djangorestframework-xml==1.3.0 djangorestframework==3.3.3 docutils==0.12 # 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 @@ -57,10 +61,11 @@ 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 +kombu==4.2.1 +linecache2==1.0.0 # via traceback2 lxml==4.2.1 markdown==2.6.6 -mock==2.0.0 # via responses +mock==2.0.0 ndg-httpsclient==0.4.2 oauthlib==1.0.3 path.py==11.0.1 @@ -78,6 +83,7 @@ pytest==3.0.3 # via pytest-django python-dateutil==2.6.0 python-digest==1.7 pytz==2016.4 +pyxform==0.11.5 requests==2.10.0 responses==0.9.0 s3transfer==0.1.11 # via boto3 @@ -87,8 +93,11 @@ sqlparse==0.1.19 static3==0.7.0 statistics==1.0.3.5 tabulate==0.7.5 +traceback2==1.4.0 # via unittest2 unicodecsv==0.14.1 +unittest2==1.1.0 # via pyxform uwsgi==2.0.17 +vine==1.1.4 # via amqp whitenoise==3.3.1 whoosh==2.7.4 xlrd==1.1.0 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/jsapp/fonts/.gitignore b/jsapp/fonts/.gitignore deleted file mode 100644 index b4e6506cfc..0000000000 --- a/jsapp/fonts/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -*.eot -*.svg -*.ttf -*.woff -*.woff2 -*.scss -*.css -*.md -*.ijmap -*.otf -codepoints -*.html \ No newline at end of file diff --git a/jsapp/js/actions.es6 b/jsapp/js/actions.es6 index 459ba71d2f..462b40da6f 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.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,33 @@ 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.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) @@ -705,19 +617,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 +680,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 +705,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 retried successfully')); +}); +actions.hooks.retryLog.failed.listen((response) => { + notify(t('Retrying submission 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..5783cbc27c 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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - , this.state.type == 'xls' || this.state.type == 'csv' ? [ - - - - , - - - - , - this.state.hierInLabels ? - - - + + + , + + - - : null, + + , + this.state.hierInLabels ? + + + + + : null, + ] : null, dvcount > 1 ? - {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')} diff --git a/jsapp/js/components/formLanding.es6 b/jsapp/js/components/formLanding.es6 index 63c93a0403..a44fa551f6 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'; @@ -45,7 +44,7 @@ export class FormLanding extends React.Component { 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; } @@ -95,7 +94,7 @@ export class FormLanding extends React.Component { ); } - sharingModal (evt) { + showSharingModal (evt) { evt.preventDefault(); stores.pageState.showModal({ type: MODAL_TYPES.SHARING, @@ -109,6 +108,22 @@ 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); + } showLanguagesModal (evt) { evt.preventDefault(); stores.pageState.showModal({ @@ -373,7 +388,7 @@ export class FormLanding extends React.Component { {userCanEdit && - + {t('Share this project')} @@ -392,14 +407,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')} } @@ -417,9 +429,9 @@ export class FormLanding extends React.Component { {t('Languages:')}  

    - {translations.map((langString)=>{ + {translations.map((langString, n)=>{ return ( -
  • +
  • {langString || t('Unnamed language')}
  • ); @@ -471,8 +483,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..7c278da12c 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,28 +19,14 @@ 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(), libraryFiltersContext: searches.getSearchContext('library', { @@ -56,8 +40,7 @@ class MainHeader extends Reflux.Component { assetType: 'asset_type:survey', }, filterTags: 'asset_type:survey', - }), - _langIndex: 0 + }) }, stores.pageState.state); this.stores = [ stores.session, @@ -69,14 +52,14 @@ 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(); @@ -200,6 +183,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 +212,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 +264,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..e3c0b3a5f6 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 = { diff --git a/jsapp/js/components/list.es6 b/jsapp/js/components/list.es6 index f6d2922e23..e43d13a65d 100644 --- a/jsapp/js/components/list.es6 +++ b/jsapp/js/components/list.es6 @@ -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/textBox.es6 b/jsapp/js/components/textBox.es6 index 6b5ca90d67..1638b5bc72 100644 --- a/jsapp/js/components/textBox.es6 +++ b/jsapp/js/components/textBox.es6 @@ -67,7 +67,7 @@ class TextBox extends React.Component { diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index 560fab4892..5ef0665a21 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -1,5 +1,11 @@ import {t} from './utils'; +const HOOK_LOG_STATUSES = { + SUCCESS: 2, + PENDING: 1, + FAILED: 0 +} + const MODAL_TYPES = { SHARING: 'sharing', UPLOADING_XLS: 'uploading-xls', @@ -8,6 +14,7 @@ const MODAL_TYPES = { SUBMISSION: 'submission', REPLACE_PROJECT: 'replace-project', TABLE_COLUMNS: 'table-columns', + REST_SERVICES: 'rest-services', FORM_LANGUAGES: 'form-languages', FORM_TRANSLATIONS_TABLE: 'form-translation-table' } @@ -75,5 +82,6 @@ export default { VALIDATION_STATUSES: VALIDATION_STATUSES, PROJECT_SETTINGS_CONTEXTS: PROJECT_SETTINGS_CONTEXTS, MODAL_TYPES: MODAL_TYPES, - ASSET_TYPES: ASSET_TYPES + ASSET_TYPES: ASSET_TYPES, + HOOK_LOG_STATUSES: HOOK_LOG_STATUSES }; diff --git a/jsapp/js/dataInterface.es6 b/jsapp/js/dataInterface.es6 index 74206330c3..b1727717b9 100644 --- a/jsapp/js/dataInterface.es6 +++ b/jsapp/js/dataInterface.es6 @@ -57,50 +57,14 @@ var dataInterface; data: data }); }, - listBlocks () { - return $ajax({ - url: `${rootUrl}/assets/?q=asset_type:block` - }); - }, listTemplates () { return $ajax({ url: `${rootUrl}/assets/?q=asset_type:template` }); }, - listSurveys() { - return $ajax({ - url: `${rootUrl}/assets/`, - data: { - q: 'asset_type:survey' - }, - method: 'GET' - }); - }, listCollections () { return $.getJSON(`${rootUrl}/collections/?all_public=true`); }, - listAllAssets () { - var d = new $.Deferred(); - $.when($.getJSON(`${rootUrl}/assets/?parent=`), $.getJSON(`${rootUrl}/collections/?parent=`)).done(function(assetR, collectionR){ - var assets = assetR[0], - collections = collectionR[0]; - var r = { - results: [], - }; - var pushItem = function (item){ - r.results.push(item); - }; - assets.results.forEach(pushItem); - collections.results.forEach(pushItem); - var sortAtt = 'date_modified'; - r.results.sort(function(a, b){ - var ad = a[sortAtt], bd = b[sortAtt]; - return (ad === bd) ? 0 : ((ad > bd) ? -1 : 1); - }); - d.resolve(r); - }).fail(d.fail); - return d.promise(); - }, createAssetSnapshot (data) { return $ajax({ url: `${rootUrl}/asset_snapshots/`, @@ -108,6 +72,72 @@ var dataInterface; data: data }); }, + + /* + * external services + */ + + getHooks(uid) { + return $ajax({ + url: `${rootUrl}/assets/${uid}/hooks/`, + method: 'GET' + }); + }, + getHook(uid, hookUid) { + return $ajax({ + url: `${rootUrl}/assets/${uid}/hooks/${hookUid}/`, + method: 'GET' + }); + }, + addExternalService(uid, data) { + return $ajax({ + url: `${rootUrl}/assets/${uid}/hooks/`, + method: 'POST', + data: JSON.stringify(data), + dataType: 'json', + contentType: 'application/json' + }); + }, + updateExternalService(uid, hookUid, data) { + return $ajax({ + url: `${rootUrl}/assets/${uid}/hooks/${hookUid}/`, + method: 'PATCH', + data: JSON.stringify(data), + dataType: 'json', + contentType: 'application/json' + }); + }, + deleteExternalService(uid, hookUid) { + return $ajax({ + url: `${rootUrl}/assets/${uid}/hooks/${hookUid}/`, + method: 'DELETE' + }); + }, + getHookLogs(uid, hookUid) { + return $ajax({ + url: `/assets/${uid}/hooks/${hookUid}/logs/`, + method: 'GET' + }) + }, + getHookLog(uid, hookUid, lid) { + return $ajax({ + url: `/assets/${uid}/hooks/${hookUid}/logs/${lid}/`, + method: 'GET' + }) + }, + retryExternalServiceLogs(uid, hookUid) { + return $ajax({ + url: `/assets/${uid}/hooks/${hookUid}/retry/`, + method: 'PATCH' + }) + }, + retryExternalServiceLog(uid, hookUid, lid) { + return $ajax({ + url: `/assets/${uid}/hooks/${hookUid}/logs/${lid}/retry/`, + method: 'PATCH' + }) + }, + getReportData (data) { let identifierString; if (data.identifiers) { @@ -199,26 +229,6 @@ var dataInterface; method: 'GET' }); }, - assetSearch ({tags, q}) { - var params = []; - if (tags) { - tags.forEach(function(tag){ - params.push(`tag:${tag}`); - }); - } - if (q) { - params.push(`(${q})`); - } - return $ajax({ - url: `${rootUrl}/assets/?${params.join(' AND ')}`, - method: 'GET' - }); - }, - readCollection ({uid}) { - return $ajax({ - url: `${rootUrl}/collections/${uid}/` - }); - }, deleteCollection ({uid}) { return $ajax({ url: `${rootUrl}/collections/${uid}/`, @@ -287,12 +297,20 @@ var dataInterface; dataType: 'html' }); }, - searchAssets (queryString) { - return $ajax({ + searchAssets (searchData) { + // override limit + searchData.limit = 200; + return $.ajax({ url: `${rootUrl}/assets/`, - data: { - q: queryString - } + dataType: 'json', + data: searchData, + method: 'GET' + }); + }, + assetsHash () { + return $ajax({ + url: `${rootUrl}/assets/hash/`, + method: 'GET' }); }, createCollection (data) { diff --git a/jsapp/js/editorMixins/assetNavigator.es6 b/jsapp/js/editorMixins/assetNavigator.es6 index 745c1549ca..2294a16cd1 100644 --- a/jsapp/js/editorMixins/assetNavigator.es6 +++ b/jsapp/js/editorMixins/assetNavigator.es6 @@ -29,10 +29,6 @@ class AssetNavigatorListView extends React.Component { this.searchClear(); this.listenTo(this.searchStore, this.searchStoreChanged); } - componentWillReceiveProps () { - this.searchClear(); - this.listenTo(this.searchStore, this.searchStoreChanged); - } searchStoreChanged (searchStoreState) { this.setState(searchStoreState); } @@ -162,20 +158,14 @@ class AssetNavigator extends Reflux.Component { autoBind(this); } componentDidMount() { - this.listenTo(stores.assetLibrary, this.assetLibraryTrigger); this.listenTo(stores.pageState, this.handlePageStateStore); this.state.searchContext.mixin.searchDefault(); } - filterSearchResults (results) { - if (this.searchFieldValue() === results.query) { - return results; + filterSearchResults (response) { + if (this.searchFieldValue() === response.query) { + return response.results; } } - assetLibraryTrigger (res) { - this.setState({ - assetLibraryItems: res - }); - } handlePageStateStore (state) { this.setState(state); } @@ -183,59 +173,8 @@ class AssetNavigator extends Reflux.Component { return this.imports.filter((i)=> i.status === n ); } searchFieldValue () { - return ReactDOM.findDOMNode(this.refs.navigatorSearchBox.refs.inp).value; - } - liveSearch () { - var queryInput = this.searchFieldValue(), - r; - if (queryInput && queryInput.length > 2) { - r = stores.assetSearch.getRecentSearch(queryInput); - if (r) { - this.setState({ - searchResults: r - }); - } else { - actions.search.assets(queryInput); - } - } - } - _displayAssetLibraryItems () { - var qresults = this.state.assetLibraryItems; - // var alItems; - // var contents; - if (qresults && qresults.count > 0) { - // var alItems = qresults.results; - return ( - - {qresults.results.map((item)=> { - var modifiers = [item.asset_type]; - // var summ = item.summary; - return ( - - - - - - - {t(item.asset_type)} - - - ); - })} - - ); - } else { - return ( - - - - {t('loading library assets')} - - - ); - } + return this.refs.navigatorSearchBox.getValue(); } - toggleTagSelected (tag) { var tags = this.state.selectedTags, _ti = tags.indexOf(tag); @@ -255,6 +194,7 @@ class AssetNavigator extends Reflux.Component { diff --git a/jsapp/js/editorMixins/cascadeMixin.es6 b/jsapp/js/editorMixins/cascadeMixin.es6 index 25704fce3a..5dd1fcfc1c 100644 --- a/jsapp/js/editorMixins/cascadeMixin.es6 +++ b/jsapp/js/editorMixins/cascadeMixin.es6 @@ -12,12 +12,7 @@ var CascadePopup = bem.create('cascade-popup'), var choiceListHelpUrl = 'http://support.kobotoolbox.org/customer/en/portal/articles/1682856'; -import { - surveyToValidJson, - notify, - assign, - t, -} from '../utils'; +import {t} from '../utils'; export default { toggleCascade () { diff --git a/jsapp/js/editorMixins/editableForm.es6 b/jsapp/js/editorMixins/editableForm.es6 index 46065d3b96..81af88fa99 100644 --- a/jsapp/js/editorMixins/editableForm.es6 +++ b/jsapp/js/editorMixins/editableForm.es6 @@ -14,6 +14,7 @@ import alertify from 'alertifyjs'; import ProjectSettings from '../components/modalForms/projectSettings'; import { surveyToValidJson, + unnullifyTranslations, notify, assign, t, @@ -167,10 +168,14 @@ class FormSettingsBox extends React.Component { } }; +const ASIDE_CACHE_NAME = 'kpi.editable-form.aside'; + export default assign({ componentDidMount() { document.body.classList.add('hide-edge'); + this.loadAsideSettings(); + if (this.state.editorState === 'existing') { let uid = this.props.params.assetid; stores.allAssets.whenLoaded(uid, (asset) => { @@ -199,6 +204,17 @@ export default assign({ this.unpreventClosingTab(); }, + loadAsideSettings() { + const asideSettings = sessionStorage.getItem(ASIDE_CACHE_NAME); + if (asideSettings) { + this.setState(JSON.parse(asideSettings)); + } + }, + + saveAsideSettings(asideSettings) { + sessionStorage.setItem(ASIDE_CACHE_NAME, JSON.stringify(asideSettings)); + }, + onFormSettingsBoxChange() { this.onSurveyChange(); }, @@ -228,6 +244,12 @@ export default assign({ this.onSurveyChange(); }, + getStyleSelectVal(optionVal) { + return _.find(AVAILABLE_FORM_STYLES, (option) => { + return option.value === optionVal; + }); + }, + onSurveyChange: _.debounce(function () { if (!this.state.asset_updated !== update_states.UNSAVED_CHANGES) { this.preventClosingTab(); @@ -287,9 +309,11 @@ export default assign({ if (this.state.name) this.app.survey.settings.set('title', this.state.name); - var params = { - source: surveyToValidJson(this.app.survey), - }; + let surveyJSON = surveyToValidJson(this.app.survey) + if (this.state.asset) { + surveyJSON = unnullifyTranslations(surveyJSON, this.state.asset.content); + } + let params = {source: surveyJSON}; params = koboMatrixParser(params); @@ -323,9 +347,11 @@ export default assign({ this.app.survey.settings.set('style', this.state.settings__style); } - let params = { - content: surveyToValidJson(this.app.survey), - }; + let surveyJSON = surveyToValidJson(this.app.survey) + if (this.state.asset) { + surveyJSON = unnullifyTranslations(surveyJSON, this.state.asset.content); + } + let params = {content: surveyJSON}; if (this.state.name) { params.name = this.state.name; @@ -454,18 +480,22 @@ export default assign({ toggleAsideLibrarySearch(evt) { evt.target.blur(); - this.setState({ + const asideSettings = { asideLayoutSettingsVisible: false, asideLibrarySearchVisible: !this.state.asideLibrarySearchVisible, - }); + }; + this.setState(asideSettings); + this.saveAsideSettings(asideSettings); }, toggleAsideLayoutSettings(evt) { evt.target.blur(); - this.setState({ + const asideSettings = { asideLayoutSettingsVisible: !this.state.asideLayoutSettingsVisible, asideLibrarySearchVisible: false - }); + }; + this.setState(asideSettings); + this.saveAsideSettings(asideSettings); }, hidePreview() { @@ -782,7 +812,7 @@ export default assign({ + onChange={this.colChangeType} + className='kobo-select' + classNamePrefix='kobo-select' + menuPlacement='auto' + />