diff --git a/.eslintrc.js b/.eslintrc.js index 1d18ed0958..ce20b001b1 100755 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,13 +1,257 @@ +const jsRules = { + 'no-empty-pattern': 1, + 'arrow-body-style': [1, 'as-needed', {requireReturnForObjectLiteral: true}], + 'arrow-parens': [1, 'always'], + 'arrow-spacing': 1, + 'block-spacing': [1, 'never'], + 'brace-style': [1, '1tbs', {allowSingleLine: true}], + 'comma-dangle': [1, 'always-multiline'], + 'comma-spacing': [1, {before: false, after: true}], + curly: 1, + 'eol-last': [1, 'always'], + eqeqeq: 1, + 'func-call-spacing': 1, + 'jsx-quotes': ['warn', 'prefer-single'], + 'key-spacing': [1, {beforeColon: false, afterColon: true, mode: 'strict'}], + 'max-nested-callbacks': [1, 6], + 'no-case-declarations': 1, + 'no-confusing-arrow': 1, + 'no-console': [1, {allow: ['info', 'warn', 'error']}], + 'no-control-regex': 0, + 'no-debugger': 1, + 'no-duplicate-imports': 1, + 'no-extend-native': 1, + 'no-extra-bind': 1, + 'no-extra-boolean-cast': 1, + 'no-extra-semi': 1, + 'no-fallthrough': 1, + 'no-inner-declarations': 1, + 'no-irregular-whitespace': 1, + 'no-lonely-if': 1, + 'no-mixed-spaces-and-tabs': 1, + 'no-multi-spaces': 1, + 'no-multiple-empty-lines': [1, {max: 2, maxEOF: 1}], + 'no-nonoctal-decimal-escape': 1, + 'no-prototype-builtins': 1, + 'no-trailing-spaces': 1, + 'no-undef-init': 1, + 'no-undef': 1, + 'no-unexpected-multiline': 1, + 'no-unsafe-optional-chaining': 1, + 'no-unused-expressions': 1, + 'no-unused-vars': 1, + 'no-useless-backreference': 1, + 'no-useless-escape': 1, + 'no-useless-rename': 1, + 'no-var': 1, + 'no-whitespace-before-property': 1, + 'one-var': ['warn', 'never'], + 'prefer-const': 1, + 'prefer-rest-params': 1, + 'prefer-spread': 1, + quotes: ['warn', 'single', {avoidEscape: true}], + 'react/jsx-boolean-value': 1, + 'react/jsx-no-undef': 2, + 'react/jsx-sort-prop-types': 0, + 'react/jsx-sort-props': 0, + 'react/jsx-uses-react': 2, + 'react/jsx-uses-vars': 2, + 'react/no-did-mount-set-state': 0, + 'react/no-did-update-set-state': 2, + 'react/no-multi-comp': 0, + 'react/no-unknown-property': 0, + 'react/prop-types': 0, + 'react/react-in-jsx-scope': 2, + 'react/self-closing-comp': 2, + 'react/wrap-multilines': 0, + 'semi-spacing': [1, {before: false, after: true}], + 'semi-style': [1, 'last'], + semi: 1, + 'space-before-function-paren': 'off', + 'space-in-parens': [1, 'never'], + 'space-infix-ops': 1, + strict: 1, +}; + +// TypeScript rules override some of JavaScript rules plus add a few more. +const tsRules = Object.assign({}, jsRules, { + '@typescript-eslint/array-type': [1, {default: 'array-simple'}], + // Would be good to enable in future, when most of codebase is TS. + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/ban-ts-comment': 1, + '@typescript-eslint/comma-dangle': [1, 'always-multiline'], + '@typescript-eslint/comma-spacing': [1, {before: false, after: true}], + '@typescript-eslint/consistent-type-definitions': [1, 'interface'], + '@typescript-eslint/consistent-type-imports': [1, {prefer: 'type-imports'}], + '@typescript-eslint/func-call-spacing': [1, 'never'], + '@typescript-eslint/keyword-spacing': [1, {before: true, after: true}], + '@typescript-eslint/member-delimiter-style': [ + 1, + { + multiline: {delimiter: 'semi'}, + singleline: {delimiter: 'semi'}, + }, + ], + '@typescript-eslint/method-signature-style': [1, 'property'], + '@typescript-eslint/naming-convention': [ + 1, + { + selector: 'variableLike', + format: ['camelCase', 'PascalCase', 'snake_case', 'UPPER_CASE'], + }, + {selector: 'memberLike', format: ['camelCase', 'PascalCase', 'snake_case']}, + {selector: 'typeLike', format: ['PascalCase']}, + { + selector: 'property', + format: ['camelCase', 'PascalCase', 'snake_case', 'UPPER_CASE'], + }, + {selector: 'method', format: ['camelCase']}, + { + selector: [ + 'classProperty', + 'objectLiteralProperty', + 'typeProperty', + 'classMethod', + 'objectLiteralMethod', + 'typeMethod', + 'accessor', + 'enumMember', + ], + format: null, + modifiers: ['requiresQuotes'], + }, + ], + '@typescript-eslint/no-confusing-non-null-assertion': 1, + '@typescript-eslint/no-dupe-class-members': 1, + '@typescript-eslint/no-dynamic-delete': 1, + '@typescript-eslint/no-empty-function': 1, + '@typescript-eslint/no-empty-interface': 1, + '@typescript-eslint/no-explicit-any': 1, + '@typescript-eslint/no-extra-parens': 'off', + '@typescript-eslint/no-extra-semi': 1, + '@typescript-eslint/no-inferrable-types': 1, + '@typescript-eslint/no-invalid-void-type': 1, + '@typescript-eslint/no-loop-func': 1, + '@typescript-eslint/no-shadow': 1, + '@typescript-eslint/no-this-alias': 1, + '@typescript-eslint/no-unused-expressions': 1, + '@typescript-eslint/no-unused-vars': 1, + '@typescript-eslint/no-use-before-define': 1, + '@typescript-eslint/no-useless-constructor': 1, + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/object-curly-spacing': 1, + '@typescript-eslint/parameter-properties': 1, + '@typescript-eslint/prefer-enum-initializers': 1, + '@typescript-eslint/prefer-optional-chain': 1, + '@typescript-eslint/quotes': ['warn', 'single', {avoidEscape: true}], + '@typescript-eslint/semi': 1, + '@typescript-eslint/sort-type-union-intersection-members': 'off', + '@typescript-eslint/space-before-function-paren': [ + 1, + { + anonymous: 'always', + named: 'never', + asyncArrow: 'always', // TODO: Defer all formatting rules to Prettier + }, + ], + '@typescript-eslint/type-annotation-spacing': [ + 1, + { + before: false, + after: true, + overrides: { + arrow: { + before: true, + after: true, + }, + }, + }, + ], + '@typescript-eslint/unified-signatures': 1, + 'comma-dangle': 'off', + 'comma-spacing': 'off', + 'func-call-spacing': 'off', + // The 'import' plugin supports separately importing types + // (@typescript-eslint/no-duplicate-imports is deprecated) + 'import/no-duplicates': 1, + // Turn off ESLint's version of this rule when in TypeScript + 'no-duplicate-imports': 'off', + 'no-nonoctal-decimal-escape': 'off', + // It is recommended that this check is disabled for TS files, see: + // https://typescript-eslint.io/docs/linting/troubleshooting/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off', + 'no-unsafe-optional-chaining': 'off', + 'no-unused-expressions': 'off', + 'no-unused-vars': 'off', + 'no-useless-backreference': 'off', + 'no-var': 1, + quotes: 'off', + semi: 'off', +}); + module.exports = { - extends: ['./node_modules/kobo-common/src/configs/.eslintrc.js', 'plugin:storybook/recommended'], - overrides: [{ - files: ['cypress/**/*.js'], - globals: { - cy: 'readonly', - Cypress: 'readonly' - }, - rules: { - semi: ['warn', 'never'] // Cypress style - } - }] -}; \ No newline at end of file + root: true, + parser: '@typescript-eslint/parser', + env: { + browser: true, + node: true, + es6: true, + }, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + ignorePatterns: ['**/*.scss'], + plugins: ['react'], + extends: ['eslint:recommended', 'prettier', 'plugin:storybook/recommended'], + rules: jsRules, + settings: { + react: { + version: 'detect', + }, + }, + overrides: [ + { + files: ['**/*.ts', '**/*.tsx'], + parser: '@typescript-eslint/parser', + plugins: [ + 'react', + '@typescript-eslint', + // For import/no-duplicates + // Could do more with it. + 'import', + ], + settings: { + 'import/resolver': { + typescript: true, + }, + }, + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + parserOptions: { + project: ['./tsconfig.json'], + }, + rules: tsRules, + }, + ], + globals: { + inject: false, + module: false, + describe: false, + it: false, + before: false, + beforeEach: false, + after: false, + afterEach: false, + expect: false, + window: false, + document: false, + Parse: false, + chai: true, + t: 'readonly', + $: 'readonly', + ga: 'readonly', + }, +}; diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e35d8f8de1..5bc93e6559 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -19,12 +19,13 @@ jobs: SERVICE_ACCOUNT_BACKEND_URL: redis://localhost:6379/4 CACHE_URL: redis://localhost:6379/3 ENKETO_REDIS_MAIN_URL: redis://localhost:6379/0 + KOBOCAT_MEDIA_ROOT: /tmp/test_media strategy: matrix: python-version: ['3.8', '3.10'] services: postgres: - image: postgis/postgis:11-3.2 + image: postgis/postgis:14-3.4 env: POSTGRES_USER: kobo POSTGRES_PASSWORD: kobo diff --git a/.prettierrc.js b/.prettierrc.js index a26cc5081a..e986dd9bd8 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,12 +1,32 @@ // .prettierrc.js module.exports = { - ...require('./node_modules/kobo-common/src/configs/.prettierrc.js'), + editorconfig: true, + // tabWidth comes from editorconfig + // useTabs comes from editorconfig + trailingComma: 'es5', + semi: true, + singleQuote: true, + quoteProps: 'as-needed', + jsxSingleQuote: true, + bracketSpacing: false, + bracketSameLine: false, + arrowParens: 'always', + endOfLine: 'lf', overrides: [ { - files: ["cypress/**/*.js"], + files: ['cypress/**/*.js'], options: { semi: false, // Cypress style }, }, + { + // Markdown configuration is mainly for our docs project (i.e. support.kobotoolbox.org) + files: 'source/*.md', + options: { + parser: 'markdown', + printWidth: 80, + proseWrap: 'always', + }, + }, ], }; diff --git a/.stylelintrc.js b/.stylelintrc.js index 55263585a4..ef76179b03 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,3 +1,481 @@ module.exports = { - extends: './node_modules/kobo-common/src/configs/.stylelintrc.js', + extends: 'stylelint-config-standard-scss', + defaultSeverity: 'warning', + rules: { + 'at-rule-empty-line-before': null, + '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': [ + 'never-single-line', + { + severity: 'warning', + }, + ], + 'block-opening-brace-newline-after': [ + 'always-multi-line', + { + severity: 'warning', + }, + ], + 'block-opening-brace-space-after': [ + 'never-single-line', + { + severity: 'warning', + }, + ], + 'block-opening-brace-space-before': [ + 'always', + { + severity: 'warning', + }, + ], + 'color-no-invalid-hex': true, + 'color-hex-case': [ + 'lower', + { + severity: 'warning', + }, + ], + 'comment-no-empty': true, + 'comment-empty-line-before': null, + 'comment-whitespace-inside': [ + 'always', + { + severity: 'warning', + }, + ], + 'custom-property-empty-line-before': null, + 'declaration-block-no-duplicate-properties': [ + true, + { + severity: 'warning', + }, + ], + 'declaration-block-no-redundant-longhand-properties': null, + 'declaration-block-no-shorthand-property-overrides': [ + true, + { + severity: 'warning', + }, + ], + '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': null, + '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', + }, + ], + // somewhat useful, yet often frivolous. removing for now. + 'no-descending-specificity': null, + 'no-duplicate-at-import-rules': true, + 'no-duplicate-selectors': [ + true, + { + severity: 'warning', + }, + ], + 'no-empty-source': true, + 'no-extra-semicolons': [ + true, + { + severity: 'warning', + }, + ], + // don't show warnings. trust editorconfig to: + // - trim end-of-line whitespace + // - ensure end-of-file newline + 'no-eol-whitespace': null, + 'no-missing-end-of-source-newline': null, + + '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', + }, + ], + // allows files like "badge.module.scss" + 'scss/at-import-partial-extension': null, + // allow group lists of variables with empty lines as separators + 'scss/dollar-variable-empty-line-before': null, + // same as comment-empty-line-before + 'scss/double-slash-comment-empty-line-before': null, + 'scss/operator-no-newline-before': null, + 'selector-class-pattern': null, + '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'}], + 'string-quotes': ['single', {severity: 'warning', avoidEscape: true}], + '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/.swcrc b/.swcrc index 298f73452a..120cb676a5 100644 --- a/.swcrc +++ b/.swcrc @@ -1,4 +1,5 @@ { + "$schema": "https://swc.rs/schema.json", "sourceMaps": true, "jsc": { "parser": { @@ -7,7 +8,7 @@ }, "transform": { "react": { - "runtime": "classic", + "runtime": "automatic", "refresh": true } } diff --git a/Dockerfile b/Dockerfile index f806b96210..92fa432353 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,8 +29,6 @@ ENV KPI_LOGS_DIR=/srv/logs \ TMP_DIR=/srv/tmp \ UWSGI_USER=kobo \ UWSGI_GROUP=kobo \ - SERVICES_DIR=/etc/service \ - CELERY_PID_DIR=/var/run/celery \ INIT_PATH=/srv/init ########################################## @@ -40,13 +38,7 @@ ENV KPI_LOGS_DIR=/srv/logs \ RUN mkdir -p "${NGINX_STATIC_DIR}" && \ mkdir -p "${KPI_SRC_DIR}" && \ mkdir -p "${KPI_NODE_PATH}" && \ - mkdir -p "${TMP_DIR}" && \ - mkdir -p ${CELERY_PID_DIR} && \ - mkdir -p ${SERVICES_DIR}/uwsgi && \ - mkdir -p ${SERVICES_DIR}/celery && \ - mkdir -p ${SERVICES_DIR}/celery_low_priority && \ - mkdir -p ${SERVICES_DIR}/celery_beat && \ - mkdir -p "${INIT_PATH}" + mkdir -p "${TMP_DIR}" ########################################## # Install `apt` packages. # @@ -63,6 +55,7 @@ RUN apt-get -qq update && \ echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_16.x nodistro main" \ | tee /etc/apt/sources.list.d/nodesource.list && \ apt-get -qq update && \ + apt-get -qq -y install openjdk-17-jre && \ apt-get -qq -y install --no-install-recommends \ ffmpeg \ gdal-bin \ @@ -76,7 +69,6 @@ RUN apt-get -qq update && \ postgresql-client \ procps \ rsync \ - runit-init \ vim-tiny \ wait-for-it && \ apt-get clean && \ @@ -161,23 +153,10 @@ RUN echo "export PATH=${PATH}" >> /etc/profile && \ echo 'source /etc/profile' >> /root/.bashrc && \ echo 'source /etc/profile' >> /home/${UWSGI_USER}/.bashrc - -# Remove getty* services to avoid errors of absent tty at sv start-up -RUN rm -rf /etc/runit/runsvdir/default/getty-tty* - -# Create symlinks for runsv services -RUN ln -s "${KPI_SRC_DIR}/docker/run_uwsgi.bash" "${SERVICES_DIR}/uwsgi/run" && \ - ln -s "${KPI_SRC_DIR}/docker/run_celery.bash" "${SERVICES_DIR}/celery/run" && \ - ln -s "${KPI_SRC_DIR}/docker/run_celery_low_priority.bash" "${SERVICES_DIR}/celery_low_priority/run" && \ - ln -s "${KPI_SRC_DIR}/docker/run_celery_beat.bash" "${SERVICES_DIR}/celery_beat/run" - - # Add/Restore `UWSGI_USER`'s permissions # chown of `${TMP_DIR}/.npm` is a hack needed for kobo-install-based staging deployments; # see internal discussion at https://chat.kobotoolbox.org/#narrow/stream/4-Kobo-Dev/topic/Unu.2C.20du.2C.20tri.2C.20kvar.20deployments/near/322075 -RUN chown -R ":${UWSGI_GROUP}" ${CELERY_PID_DIR} && \ - chmod g+w ${CELERY_PID_DIR} && \ - chown -R "${UWSGI_USER}:${UWSGI_GROUP}" ${KPI_SRC_DIR}/emails/ && \ +RUN chown -R "${UWSGI_USER}:${UWSGI_GROUP}" ${KPI_SRC_DIR}/emails/ && \ chown -R "${UWSGI_USER}:${UWSGI_GROUP}" ${KPI_LOGS_DIR} && \ chown -R "${UWSGI_USER}:${UWSGI_GROUP}" ${TMP_DIR} && \ chown -R root:root "${TMP_DIR}/.npm" @@ -185,4 +164,4 @@ RUN chown -R ":${UWSGI_GROUP}" ${CELERY_PID_DIR} && \ EXPOSE 8000 -CMD ["/bin/bash", "-c", "exec ${KPI_SRC_DIR}/docker/init.bash"] +CMD ["/bin/bash", "docker/entrypoint.sh"] diff --git a/README.md b/README.md index e1039dd3b2..ca41285963 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,17 @@ We're open for [contributions](./CONTRIBUTING.md)! +## Important notice when upgrading from any release older than [`2.024.19`](https://github.com/kobotoolbox/kpi/releases/tag/2.024.19) + +Prior to release [`2.024.19`](https://github.com/kobotoolbox/kpi/releases/tag/2.024.19), this project (KPI) and [KoboCAT](https://github.com/kobotoolbox/kobocat) were two separated projects. +KoboCAT is now part of KPI code base and its repository has been archived. + +KoboCAT deprecation notices will be maintained in this repository. +[More details here](./kobo/apps/openrosa/README.md) + ## Important notice when upgrading from any release older than [`2.020.18`](https://github.com/kobotoolbox/kpi/releases/tag/2.020.18) -Prior to release [`2.020.18`](https://github.com/kobotoolbox/kpi/releases/tag/2.020.18), this project (KPI) and [KoBoCAT](https://github.com/kobotoolbox/kobocat) both shared a common Postgres database. They now each have their own. **If you are upgrading an existing single-database installation, you must follow [these instructions](https://community.kobotoolbox.org/t/upgrading-to-separate-databases-for-kpi-and-kobocat/7202)** to migrate the KPI tables to a new database and adjust your configuration appropriately. +Prior to release [`2.020.18`](https://github.com/kobotoolbox/kpi/releases/tag/2.020.18), this project (KPI) and [KoboCAT](https://github.com/kobotoolbox/kobocat) both shared a common Postgres database. They now each have their own. **If you are upgrading an existing single-database installation, you must follow [these instructions](https://community.kobotoolbox.org/t/upgrading-to-separate-databases-for-kpi-and-kobocat/7202)** to migrate the KPI tables to a new database and adjust your configuration appropriately. If you do not want to upgrade at this time, please use the [`shared-database-obsolete`](https://github.com/kobotoolbox/kpi/tree/shared-database-obsolete) branch instead. @@ -35,7 +43,7 @@ syntax, see the documentation at the top of ## Admin reports -There are several types of data reports available to superusers. +There are several types of data reports available to superusers. * Full list of users including their details provided during signup, number of deployed projects (XForm count), number of submissions, date joined, and last login: `/superuser_stats/user_report/`. File being created is a CSV, so don't download immediately to wait for server to be finished writing to the file (it will download even if incomplete). * Monthly aggregate figures for number of forms, deployed projects, and submissions (from kobocat): `//superuser_stats/` diff --git a/cypress/README.md b/cypress/README.md index 943a3846cc..614ac74c55 100644 --- a/cypress/README.md +++ b/cypress/README.md @@ -2,27 +2,40 @@ To run tests, you need to do 2 things: -1. Start the **test server.** This is like a kpi server, but in a special cypress_testserver mode. (You will also need to restart it between Cypress test runs.) -2. Use the **Cypress test runner.** You can run this in command-line mode, or open an interactive browser window. +1. [Start the **test server.**](#how-to-start-the-test-server) This is like a kpi server, but in a special cypress_testserver mode. (You will also need to restart it between Cypress test runs.) +2. [Use the **Cypress test runner.**](#how-to-run-cypress-tests) You can run this in command-line mode, or open an interactive browser window. ## How to start the test server If you normally run kpi with `./manage.py runserver 0.0.0.0:8000`, you can use: ``` -kpi$ DJANGO_SETTINGS_MODULE=kobo.settings.testing \ +kpi$ DJANGO_SETTINGS_MODULE=kobo.settings.cypress \ ./manage.py cypress_testserver \ --addrport 0.0.0.0:8000 \ --noinput ``` -If you're using kobo-docker / kobo-install, the process will look like this: +If you're using [kobo-install](https//github.com/kobotoolbox/kobo-install): ```console +# Enter a bash session in the kpi container kobo-install$ ./run.py -cf exec kpi bash -root@kpi:/srv/src/kpi# sv stop uwsgi -ok: down: uwsgi: 0s, normally up -root@kpi:/srv/src/kpi# DJANGO_SETTINGS_MODULE=kobo.settings.testing ./manage.py cypress_testserver --addrport 0.0.0.0:8000 --noinput + +# Stop the server that is already running +root@kpi:/srv/src/kpi# sv stop uwsgi + ok: down: uwsgi: 0s, normally up + +# Start the test server +root@kpi:/srv/src/kpi# DJANGO_SETTINGS_MODULE=kobo.settings.cypress ./manage.py cypress_testserver --addrport 0.0.0.0:8000 --noinput +``` + +Note: If you get a ModuleNotFoundError, you may need to install +the dev dependencies. In your container: + +```console +# Install dev dependencies (so you can run tests) +pip install -r dependencies/pip/dev_requirements.txt ```
About cypress_testserver @@ -32,13 +45,13 @@ root@kpi:/srv/src/kpi# DJANGO_SETTINGS_MODULE=kobo.settings.testing ./manage.py The **cypress_testserver** provides fixtures for the Cypress tests. ``` -DJANGO_SETTINGS_MODULE=kobo.settings.testing (1) Use test server settings +DJANGO_SETTINGS_MODULE=kobo.settings.cypress (1) Use test server settings ./manage.py cypress_testserver (2) Run the test server --addrport 0.0.0.0:8000 (3) Bind :8000 (check this) --noinput (4) Skip 'delete database' prompt ``` -1. `DJANGO_SETTINGS_MODULE=kobo.settings.testing` switches the server away from using your default kpi database. Source: [kpi/kobo/settings/testing.py](../kobo/settings/testing.py) +1. `DJANGO_SETTINGS_MODULE=kobo.settings.cypress` switches the server away from using your default kpi database. Source: [kpi/kobo/settings/cypress.py](../kobo/settings/cypress.py) 2. `./manage.py cypress_testserver` is a custom management command. Starts a test server with fixtures created in Python specifically for Cypress tests. - [kpi/management/commands/cypress_testserver.py](../kpi/management/commands/cypress_testserver.py) - Add or change fixtures here. - [django-admin/#testserver](https://docs.djangoproject.com/en/4.0/ref/django-admin/#testserver) - Django's built-in `testserver`, which this is based on. @@ -55,13 +68,13 @@ Between subsequent Cypress test runs, you'll need to restart the test server to ### Installing Cypress -1. Navigate to the `cypress` folder. -2. Install cypress with `npm install`. +1. Navigate to the `cypress` folder. +2. Install cypress with `npm install`. Cypress will likely ask you to install [some OS dependencies](https://on.cypress.io/required-dependencies) (about .5 GB) when you try to run a test.
-(Make sure `$KOBOFORM_URL` or `$CYPRESS_BASE_URL` points to your test server.) +Make sure `$KOBOFORM_URL` or `$CYPRESS_BASE_URL` points to your test server. ### Command line only tests @@ -86,7 +99,7 @@ If you're on a computer with limited resources, you may wish to use these: screenshotOnRunFailure=false Disable screenshots of Cypress tests ``` -For example, to run the command-line only tests with the above options, use `npx cypress run --config video=false,screenshotOnRunFailure=false`. +For example, to run the command-line only tests with the above options, use `npx cypress run --config video=false,screenshotOnRunFailure=false`. Alternatively, you could set the environment variables `CYPRESS_VIDEO` and `CYPRESS_SCREENSHOT_ON_RUN_FAILURE`. @@ -94,8 +107,7 @@ Alternatively, you could set the environment variables `CYPRESS_VIDEO` and `CYPR We have configured Cypress to read its baseUrl from `KOBOFORM_URL` because it's likely you'll already have that set from kpi or kobo-install. -You can override this with `CYPRESS_BASE_URL`, or any other method of configuration. - +You can override this with `CYPRESS_BASE_URL`, or the config option equivalent, `baseUrl`. # Writing tests diff --git a/cypress/cypress.json b/cypress/cypress.json index 86bec656c8..72884a116a 100644 --- a/cypress/cypress.json +++ b/cypress/cypress.json @@ -3,5 +3,7 @@ "project.spec.js", "question.spec.js", "delete.spec.js" - ] + ], + "viewportWidth": 1440, + "viewportHeight": 900 } diff --git a/cypress/cypress/integration/delete.spec.js b/cypress/cypress/integration/delete.spec.js index 7984f827fa..adcae2bf42 100644 --- a/cypress/cypress/integration/delete.spec.js +++ b/cypress/cypress/integration/delete.spec.js @@ -10,24 +10,25 @@ describe('Delete Project.', function () { it('Cancels deleting a project', function () { - cy.get('[data-cy="buttons"]') - .invoke('attr', 'style', 'visibility: visible;') + // Select the project to activate the "project actions" buttons, + // then click the 'delete' button. + cy.get('[data-field="checkbox"] .checkbox__input') + .should('exist') + .click() .then(() => { - cy.get('[data-tip="More actions"]') + cy.get('[aria-label="Delete 1 project"]') .should('exist') .click() }) - cy.get('a[data-action="delete"]') - .should('exist') - .click() - - cy.get('[data-cy="checkbox"]') + // Check every checkbox in the confirmation modal, + // then click the confirmation "Delete" button + cy.get(' .ajs-dialog [data-cy="checkbox"]') .each(($box) => { cy.wrap($box) .click() }).then(() => - cy.get('[data-cy="delete"]') + cy.get('.ajs-dialog [data-cy="delete"]') .click() ) diff --git a/cypress/cypress/integration/question.spec.js b/cypress/cypress/integration/question.spec.js index c849bd0136..ec8861c394 100644 --- a/cypress/cypress/integration/question.spec.js +++ b/cypress/cypress/integration/question.spec.js @@ -10,8 +10,10 @@ describe('Create questions', function () { it('Creates questions', function () { cy.fixture('questions').then((data) => { - cy.get('[data-cy="question"]') + // Click the asset name to load its form summary page + cy.get('[data-cy="asset"]') .click() + // Click the "edit" button to get to the Formbuilder cy.get('[data-cy="edit"]') .click() diff --git a/cypress/cypress/support/commands.js b/cypress/cypress/support/commands.js index 75df5f4492..abd9833110 100644 --- a/cypress/cypress/support/commands.js +++ b/cypress/cypress/support/commands.js @@ -4,8 +4,8 @@ Cypress.Commands.add('setupDatabase', () => { Cypress.Commands.add('login', (account, name) => { cy.visit('/accounts/login/') - cy.get('input[name="username"]').type(name) - cy.get('input[name="password"]').type(account.password) + cy.get('#id_login').type(name) + cy.get('#id_password').type(account.password) cy.contains('Login').click() }) diff --git a/dependencies/pip/dev_requirements.in b/dependencies/pip/dev_requirements.in index 87c5c03015..ce4a55e5c0 100644 --- a/dependencies/pip/dev_requirements.in +++ b/dependencies/pip/dev_requirements.in @@ -3,7 +3,7 @@ Fabric coverage coveralls -ipython +ipython==8.12.3 # Max supported version by Python 3.8 mock model-bakery mongomock @@ -11,3 +11,8 @@ pytest pytest-cov pytest-django pytest-env + + +# Kobocat +httmock +simplejson diff --git a/dependencies/pip/dev_requirements.txt b/dependencies/pip/dev_requirements.txt index 8a51784baf..2b8d3377ad 100644 --- a/dependencies/pip/dev_requirements.txt +++ b/dependencies/pip/dev_requirements.txt @@ -4,123 +4,137 @@ # # pip-compile dependencies/pip/dev_requirements.in # --e git+https://github.com/dimagi/django-digest@419f7306443f9a800b07d832b2cc147941062d59#egg=django_digest +-e git+https://github.com/kobotoolbox/django-digest@43f3100a7e257942c313ad79057e6a0b1612a74a#egg=django_digest # via -r dependencies/pip/requirements.in -e git+https://github.com/trevoriancox/django-dont-vary-on.git@01a804122b7ddcdc22f50b40993f91c27b03bef6#egg=django-dont-vary-on # via -r dependencies/pip/requirements.in --e git+https://github.com/kobotoolbox/formpack.git@3b6c89a00c77693a775ef91b68d6965678a7e4fe#egg=formpack +-e git+https://github.com/kobotoolbox/formpack.git@451df4cd2a0d614be69a3b3309259c67369f7efb#egg=formpack # via -r dependencies/pip/requirements.in --e git+https://github.com/kobotoolbox/kobo-service-account.git@871762cdd099ed543d36f0a29bfbff1de4766a8b#egg=kobo-service-account +-e git+https://github.com/kobotoolbox/kobo-service-account.git@cb52c6221b68af9b13237d0a1157e3f1965a82b1#egg=kobo-service-account # via -r dependencies/pip/requirements.in -e git+https://github.com/dimagi/python-digest@5c94bb74516b977b60180ee832765c0695ff2b56#egg=python_digest # via -r dependencies/pip/requirements.in -e git+https://github.com/kobotoolbox/ssrf-protect@9b97d3f0fd8f737a38dd7a6b64efeffc03ab3cdd#egg=ssrf_protect # via -r dependencies/pip/requirements.in -amqp==5.1.1 +aiohttp==3.9.3 + # via + # aiohttp-retry + # twilio +aiohttp-retry==2.8.3 + # via twilio +aiosignal==1.3.1 + # via aiohttp +amqp==5.2.0 # via # -r dependencies/pip/requirements.in # kombu -asgiref==3.5.0 - # via django -asttokens==2.0.5 +asgiref==3.8.1 + # via + # django + # django-cors-headers +asttokens==2.4.1 # via stack-data -async-timeout==4.0.2 - # via redis -attrs==21.4.0 +async-timeout==4.0.3 # via + # aiohttp + # redis +attrs==23.2.0 + # via + # aiohttp # jsonschema - # pytest -azure-core==1.24.1 + # referencing +azure-core==1.30.1 # via # azure-storage-blob - # msrest -azure-storage-blob==12.12.0 + # django-storages +azure-storage-blob==12.19.1 # via django-storages backcall==0.2.0 # via ipython -bcrypt==3.2.0 +bcrypt==4.1.2 # via paramiko begins==0.9 # via formpack -billiard==3.6.4.0 +billiard==4.2.0 # via # -r dependencies/pip/requirements.in # celery -boto3==1.22.2 +boto3==1.34.75 # via # django-amazon-ses # django-storages -botocore==1.25.2 +botocore==1.34.75 # via # boto3 # s3transfer -cachetools==5.2.0 +cachetools==5.3.3 # via google-auth -celery[redis]==5.2.6 +celery[redis]==5.3.6 # via # -r dependencies/pip/requirements.in # django-celery-beat # flower -certifi==2021.10.8 +certifi==2024.2.2 # via - # msrest # requests # sentry-sdk -cffi==1.15.0 +cffi==1.16.0 # via - # bcrypt # cryptography # pynacl -charset-normalizer==2.0.12 +charset-normalizer==3.3.2 # via requests -click==8.1.2 +click==8.1.7 # via # celery # click-didyoumean # click-plugins # click-repl -click-didyoumean==0.3.0 +click-didyoumean==0.3.1 # via celery click-plugins==1.1.1 # via celery -click-repl==0.2.0 +click-repl==0.3.0 # via celery -coverage[toml]==6.3.2 +coverage[toml]==6.5.0 # via # -r dependencies/pip/dev_requirements.in # coveralls # pytest-cov coveralls==3.3.1 # via -r dependencies/pip/dev_requirements.in -cryptography==37.0.1 +cron-descriptor==1.4.3 + # via django-celery-beat +cryptography==42.0.5 # via # azure-storage-blob # jwcrypto # paramiko # pyjwt # pyopenssl -cssselect==1.1.0 +cssselect==1.2.0 # via pyquery decorator==5.1.1 - # via ipython -deepmerge==1.0.1 + # via + # fabric + # ipython +deepmerge==1.1.1 # via -r dependencies/pip/requirements.in defusedxml==0.7.1 # via + # -r dependencies/pip/requirements.in # djangorestframework-xml # python3-openid # pyxform -deprecated==1.2.13 - # via - # jwcrypto - # redis -dict2xml==1.7.1 +deprecated==1.2.14 + # via fabric +dict2xml==1.7.5 # via -r dependencies/pip/requirements.in dj-static==0.0.6 # via -r dependencies/pip/requirements.in -dj-stripe==2.7.3 +dj-stripe==2.8.3 # via -r dependencies/pip/requirements.in -django==3.2.15 +django==4.2.11 # via # -r dependencies/pip/requirements.in # dj-stripe @@ -134,6 +148,7 @@ django==3.2.15 # django-dont-vary-on # django-extensions # django-filter + # django-guardian # django-markdownx # django-oauth-toolkit # django-organizations @@ -147,189 +162,222 @@ django==3.2.15 # jsonfield # kobo-service-account # model-bakery -django-allauth==0.57.0 +django-allauth==0.61.1 # via -r dependencies/pip/requirements.in django-amazon-ses==4.0.1 # via -r dependencies/pip/requirements.in django-braces==1.15.0 # via -r dependencies/pip/requirements.in -django-celery-beat==2.2.1 +django-celery-beat==2.6.0 # via -r dependencies/pip/requirements.in -django-constance[database]==2.9.1 +django-constance==3.1.0 # via -r dependencies/pip/requirements.in -django-cors-headers==3.11.0 +django-cors-headers==4.3.1 # via -r dependencies/pip/requirements.in -django-csp==3.7 +django-csp==3.8 # via -r dependencies/pip/requirements.in -django-debug-toolbar==3.2.4 +django-debug-toolbar==4.3.0 # via -r dependencies/pip/requirements.in -django-environ==0.8.1 +django-environ==0.11.2 # via -r dependencies/pip/requirements.in -django-extensions==3.1.5 +django-extensions==3.2.3 + # via + # -r dependencies/pip/requirements.in + # django-organizations +django-filter==24.2 # via -r dependencies/pip/requirements.in -django-filter==21.1 +django-guardian==2.4.0 # via -r dependencies/pip/requirements.in -django-loginas==0.3.10 +django-loginas==0.3.11 # via -r dependencies/pip/requirements.in -django-markdownx==4.0.2 +django-markdownx==4.0.7 # via -r dependencies/pip/requirements.in -django-oauth-toolkit==2.0.0 +django-oauth-toolkit==2.3.0 # via -r dependencies/pip/requirements.in -django-organizations==2.0.2 +django-organizations==2.4.1 # via -r dependencies/pip/requirements.in -django-picklefield==3.0.1 +django-picklefield==3.1 # via django-constance -django-private-storage==3.0 +django-private-storage==3.1.1 # via -r dependencies/pip/requirements.in -django-prometheus==2.2.0 +django-prometheus==2.3.1 # via -r dependencies/pip/requirements.in -django-redis==5.2.0 +django-redis==5.4.0 # via -r dependencies/pip/requirements.in django-redis-sessions==0.6.2 # via -r dependencies/pip/requirements.in django-request-cache==1.4.0 # via -r dependencies/pip/requirements.in -django-reversion==5.0.0 +django-reversion==5.0.12 # via -r dependencies/pip/requirements.in -django-storages[azure,boto3]==1.13.2 +django-storages[azure,boto3]==1.14.2 # via -r dependencies/pip/requirements.in -django-taggit==2.1.0 +django-taggit==5.0.1 # via -r dependencies/pip/requirements.in -django-timezone-field==4.2.3 +django-timezone-field==6.1.0 # via django-celery-beat django-trench==0.3.1 # via -r dependencies/pip/requirements.in -django-userforeignkey==0.4.0 +django-userforeignkey==0.5.0 # via django-request-cache -django-webpack-loader==2.0.1 +django-webpack-loader==3.0.1 # via -r dependencies/pip/requirements.in -djangorestframework==3.13.1 +djangorestframework==3.15.1 # via # -r dependencies/pip/requirements.in + # djangorestframework-csv # drf-extensions # kobo-service-account +djangorestframework-csv==3.0.2 + # via -r dependencies/pip/requirements.in +djangorestframework-jsonp==1.0.2 + # via -r dependencies/pip/requirements.in djangorestframework-xml==2.0.0 # via -r dependencies/pip/requirements.in +dnspython==2.6.1 + # via pymongo docopt==0.6.2 # via coveralls -docutils==0.18.1 +docutils==0.20.1 # via statistics drf-extensions==0.7.1 # via -r dependencies/pip/requirements.in et-xmlfile==1.1.0 # via openpyxl -executing==0.8.3 +exceptiongroup==1.2.0 + # via pytest +executing==2.0.1 # via stack-data -fabric==2.7.0 +fabric==3.2.2 # via -r dependencies/pip/dev_requirements.in -flower==1.2.0 +flower==2.0.1 # via -r dependencies/pip/requirements.in -future==0.18.2 +frozenlist==1.4.1 + # via + # aiohttp + # aiosignal +future==1.0.0 # via -r dependencies/pip/requirements.in -geojson-rewind==1.0.2 +geojson-rewind==1.1.0 # via # -r dependencies/pip/requirements.in # formpack -google-api-core[grpc]==2.8.2 +google-api-core[grpc]==2.18.0 # via # google-api-python-client # google-cloud-core # google-cloud-speech # google-cloud-storage # google-cloud-translate -google-api-python-client==2.62.0 +google-api-python-client==2.124.0 # via -r dependencies/pip/requirements.in -google-auth==2.8.0 +google-auth==2.29.0 # via # google-api-core # google-api-python-client # google-auth-httplib2 # google-cloud-core + # google-cloud-speech # google-cloud-storage -google-auth-httplib2==0.1.0 + # google-cloud-translate +google-auth-httplib2==0.2.0 # via google-api-python-client -google-cloud-core==2.3.1 +google-cloud-core==2.4.1 # via # google-cloud-storage # google-cloud-translate -google-cloud-speech==2.14.1 +google-cloud-speech==2.25.1 # via -r dependencies/pip/requirements.in -google-cloud-storage==2.4.0 +google-cloud-storage==2.16.0 # via -r dependencies/pip/requirements.in -google-cloud-translate==3.7.4 +google-cloud-translate==3.15.3 # via -r dependencies/pip/requirements.in -google-crc32c==1.3.0 - # via google-resumable-media -google-resumable-media==2.3.3 +google-crc32c==1.5.0 + # via + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.0 # via google-cloud-storage -googleapis-common-protos==1.56.2 +googleapis-common-protos==1.63.0 # via # google-api-core # grpcio-status -grpcio==1.46.3 +grpcio==1.62.1 # via # google-api-core # grpcio-status -grpcio-status==1.46.3 +grpcio-status==1.62.1 # via google-api-core -httplib2==0.20.4 +httmock==1.4.0 + # via -r dependencies/pip/dev_requirements.in +httplib2==0.22.0 # via # google-api-python-client # google-auth-httplib2 -humanize==4.6.0 +humanize==4.9.0 # via flower -idna==3.3 - # via requests -iniconfig==1.1.1 +idna==3.6 + # via + # requests + # yarl +iniconfig==2.0.0 # via pytest -invoke==1.7.0 +invoke==2.2.0 # via fabric -ipython==8.2.0 +ipython==8.12.3 # via -r dependencies/pip/dev_requirements.in isodate==0.6.1 - # via msrest -jedi==0.18.1 + # via azure-storage-blob +jedi==0.19.1 # via ipython -jmespath==1.0.0 +jmespath==1.0.1 # via # boto3 # botocore jsonfield==3.1.0 - # via - # -r dependencies/pip/requirements.in - # dj-stripe -jsonschema==4.4.0 + # via -r dependencies/pip/requirements.in +jsonschema==4.21.1 # via # -r dependencies/pip/requirements.in # formpack -jwcrypto==1.2 +jsonschema-specifications==2023.12.1 + # via jsonschema +jwcrypto==1.5.6 # via django-oauth-toolkit -kombu==5.2.4 +kombu==5.3.6 # via # -r dependencies/pip/requirements.in # celery -lxml==4.8.0 +lxml==5.2.0 # via # -r dependencies/pip/requirements.in # formpack # pyquery -markdown==3.3.6 +markdown==3.6 # via # -r dependencies/pip/requirements.in # django-markdownx -matplotlib-inline==0.1.3 +markupsafe==2.1.5 + # via werkzeug +matplotlib-inline==0.1.6 # via ipython -mock==4.0.3 +mock==5.1.0 # via -r dependencies/pip/dev_requirements.in -model-bakery==1.7.0 +model-bakery==1.17.0 # via -r dependencies/pip/dev_requirements.in -mongomock==4.0.0 +modilabs-python-utils==0.1.5 + # via -r dependencies/pip/requirements.in +mongomock==4.1.2 # via -r dependencies/pip/dev_requirements.in -msrest==0.7.1 - # via azure-storage-blob +multidict==6.0.5 + # via + # aiohttp + # yarl ndg-httpsclient==0.5.1 # via -r dependencies/pip/requirements.in -oauthlib==3.2.0 +numpy==1.24.4 + # via pandas +oauthlib==3.2.2 # via # -r dependencies/pip/requirements.in # django-oauth-toolkit @@ -338,42 +386,42 @@ openpyxl==3.0.9 # via # -r dependencies/pip/requirements.in # pyxform -packaging==21.3 +packaging==24.0 # via # mongomock # pytest - # redis -paramiko==2.10.4 +pandas==2.0.3 + # via -r dependencies/pip/requirements.in +paramiko==3.4.0 # via fabric parso==0.8.3 # via jedi -path==16.4.0 +path==16.10.0 # via path-py path-py==12.5.0 # via formpack -pathlib2==2.3.7.post1 - # via fabric -pexpect==4.8.0 +pexpect==4.9.0 # via ipython pickleshare==0.7.5 # via ipython -pillow==9.1.0 +pillow==10.3.0 # via django-markdownx -pluggy==1.0.0 +pluggy==1.4.0 # via pytest -prometheus-client==0.16.0 +prometheus-client==0.20.0 # via # django-prometheus # flower -prompt-toolkit==3.0.29 +prompt-toolkit==3.0.43 # via # click-repl # ipython -proto-plus==1.20.6 +proto-plus==1.23.0 # via + # google-api-core # google-cloud-speech # google-cloud-translate -protobuf==3.20.1 +protobuf==4.25.3 # via # google-api-core # google-cloud-speech @@ -381,92 +429,90 @@ protobuf==3.20.1 # googleapis-common-protos # grpcio-status # proto-plus -psycopg2==2.9.3 +psycopg==3.1.18 # via -r dependencies/pip/requirements.in ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data -py==1.11.0 - # via pytest -pyasn1==0.4.8 +pyasn1==0.6.0 # via # -r dependencies/pip/requirements.in # ndg-httpsclient # pyasn1-modules # rsa -pyasn1-modules==0.2.8 +pyasn1-modules==0.4.0 # via google-auth -pycparser==2.21 +pycparser==2.22 # via cffi -pygments==2.12.0 +pygments==2.17.2 # via # -r dependencies/pip/requirements.in # ipython -pyjwt[crypto]==2.3.0 +pyjwt[crypto]==2.8.0 # via # django-allauth # twilio -pymongo==3.12.3 +pymongo==4.6.3 # via -r dependencies/pip/requirements.in pynacl==1.5.0 # via paramiko -pyopenssl==22.0.0 +pyopenssl==24.1.0 # via # -r dependencies/pip/requirements.in # ndg-httpsclient -pyotp==2.6.0 +pyotp==2.9.0 # via django-trench -pyparsing==3.0.8 - # via - # httplib2 - # packaging -pyquery==1.4.3 +pyparsing==3.1.2 + # via httplib2 +pyquery==2.0.0 # via formpack -pyrsistent==0.18.1 - # via jsonschema -pytest==7.1.2 +pytest==8.1.1 # via # -r dependencies/pip/dev_requirements.in # pytest-cov # pytest-django # pytest-env -pytest-cov==3.0.0 +pytest-cov==5.0.0 # via -r dependencies/pip/dev_requirements.in -pytest-django==4.5.2 +pytest-django==4.8.0 # via -r dependencies/pip/dev_requirements.in -pytest-env==0.6.2 +pytest-env==1.1.3 # via -r dependencies/pip/dev_requirements.in -python-crontab==2.6.0 +python-crontab==3.0.0 # via django-celery-beat -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 # via # -r dependencies/pip/requirements.in # botocore + # celery + # pandas # python-crontab python3-openid==3.2.0 # via django-allauth -pytz==2022.1 +pytz==2024.1 # via - # celery - # django - # django-timezone-field - # djangorestframework # flower - # twilio + # pandas pyxform==1.9.0 # via # -r dependencies/pip/requirements.in # formpack -redis==4.2.2 +pyyaml==6.0.1 + # via responses +redis==5.0.3 # via # celery # django-redis # django-redis-sessions # kobo-service-account -regex==2023.6.3 +referencing==0.34.0 + # via + # jsonschema + # jsonschema-specifications +regex==2023.12.25 # via -r dependencies/pip/requirements.in -requests==2.27.1 +requests==2.31.0 # via # -r dependencies/pip/requirements.in # azure-core @@ -475,52 +521,47 @@ requests==2.27.1 # django-oauth-toolkit # google-api-core # google-cloud-storage - # msrest + # httmock # requests-oauthlib # responses # smsapi-client # stripe # twilio # yubico-client -requests-oauthlib==1.3.1 - # via - # django-allauth - # msrest -responses==0.20.0 +requests-oauthlib==2.0.0 + # via django-allauth +responses==0.25.0 # via -r dependencies/pip/requirements.in -rsa==4.8 +rpds-py==0.18.0 + # via + # jsonschema + # referencing +rsa==4.9 # via google-auth -s3transfer==0.5.2 +s3transfer==0.10.1 # via boto3 sentinels==1.0.0 # via mongomock -sentry-sdk==1.5.12 +sentry-sdk==1.44.0 # via -r dependencies/pip/requirements.in -shortuuid==1.0.8 +shortuuid==1.0.13 # via -r dependencies/pip/requirements.in +simplejson==3.19.2 + # via -r dependencies/pip/dev_requirements.in six==1.16.0 # via # asttokens # azure-core - # bcrypt - # click-repl - # django-organizations - # google-auth - # google-auth-httplib2 - # grpcio # isodate - # mongomock - # paramiko - # pathlib2 # python-dateutil -smsapi-client==2.6.0 +smsapi-client==2.9.5 # via django-trench -sqlparse==0.4.2 +sqlparse==0.4.4 # via # -r dependencies/pip/requirements.in # django # django-debug-toolbar -stack-data==0.2.0 +stack-data==0.6.3 # via ipython static3==0.7.0 # via @@ -528,51 +569,62 @@ static3==0.7.0 # dj-static statistics==1.0.3.5 # via formpack -stripe==4.1.0 +stripe==4.2.0 # via dj-stripe -tabulate==0.8.9 +tabulate==0.9.0 # via -r dependencies/pip/requirements.in tomli==2.0.1 # via # coverage # pytest -tornado==6.2 + # pytest-env +tornado==6.4 # via flower -traitlets==5.1.1 +traitlets==5.14.2 # via # ipython # matplotlib-inline -twilio==7.8.2 +twilio==9.0.3 # via django-trench -typing-extensions==4.2.0 - # via azure-core +typing-extensions==4.10.0 + # via + # asgiref + # azure-core + # azure-storage-blob + # jwcrypto + # psycopg +tzdata==2024.1 + # via + # celery + # django-celery-beat + # pandas uritemplate==4.1.1 # via google-api-python-client -urllib3==1.26.9 +urllib3==1.26.18 # via # botocore # requests # responses # sentry-sdk -uwsgi==2.0.21 +uwsgi==2.0.24 # via -r dependencies/pip/requirements.in -vine==5.0.0 +vine==5.1.0 # via # amqp # celery # kombu -wcwidth==0.2.5 +wcwidth==0.2.13 # via prompt-toolkit -werkzeug==2.0.3 +werkzeug==3.0.2 # via -r dependencies/pip/requirements.in -wrapt==1.14.0 +wrapt==1.16.0 # via deprecated xlrd==2.0.1 # via # -r dependencies/pip/requirements.in # pyxform # xlutils -xlsxwriter==3.0.3 +xlsxwriter==3.2.0 # via # -r dependencies/pip/requirements.in # formpack @@ -582,9 +634,8 @@ xlwt==1.3.0 # via # -r dependencies/pip/requirements.in # xlutils +yarl==1.9.4 + # via aiohttp yubico-client==1.13.0 # via django-trench - -# The following packages are considered to be unsafe in a requirements file: -# setuptools backports-zoneinfo==0.2.1; python_version < '3.9' diff --git a/dependencies/pip/requirements.in b/dependencies/pip/requirements.in index b0fd2fbed8..c6183bcded 100644 --- a/dependencies/pip/requirements.in +++ b/dependencies/pip/requirements.in @@ -2,15 +2,15 @@ # https://github.com/bndr/pipreqs is a handy utility, too. # formpack --e git+https://github.com/kobotoolbox/formpack.git@3b6c89a00c77693a775ef91b68d6965678a7e4fe#egg=formpack +-e git+https://github.com/kobotoolbox/formpack.git@451df4cd2a0d614be69a3b3309259c67369f7efb#egg=formpack # service-account --e git+https://github.com/kobotoolbox/kobo-service-account.git@871762cdd099ed543d36f0a29bfbff1de4766a8b#egg=kobo-service-account +-e git+https://github.com/kobotoolbox/kobo-service-account.git@cb52c6221b68af9b13237d0a1157e3f1965a82b1#egg=kobo-service-account # More up-to-date version of django-digest than PyPI seems to have. # Also, python-digest is an unlisted dependency thereof. -e git+https://github.com/dimagi/python-digest@5c94bb74516b977b60180ee832765c0695ff2b56#egg=python_digest --e git+https://github.com/dimagi/django-digest@419f7306443f9a800b07d832b2cc147941062d59#egg=django_digest +-e git+https://github.com/kobotoolbox/django-digest@43f3100a7e257942c313ad79057e6a0b1612a74a#egg=django_digest # ssrf protect -e git+https://github.com/kobotoolbox/ssrf-protect@9b97d3f0fd8f737a38dd7a6b64efeffc03ab3cdd#egg=ssrf_protect @@ -20,7 +20,7 @@ -e git+https://github.com/trevoriancox/django-dont-vary-on.git@01a804122b7ddcdc22f50b40993f91c27b03bef6#egg=django-dont-vary-on # Regular PyPI packages -Django>=3.2,<3.3 +Django>=4.2,<4.3 Markdown Pygments amqp @@ -28,12 +28,13 @@ billiard celery celery[redis] dict2xml +defusedxml dj-static dj-stripe django-allauth django-braces django-celery-beat -django-constance[database] +django-constance django-cors-headers django-csp django-debug-toolbar @@ -72,8 +73,8 @@ lxml oauthlib openpyxl #py-gfm # Incompatible with markdown 3.x -psycopg2 -pymongo==3.12.3 +psycopg +pymongo python-dateutil pyxform==1.9.0 requests @@ -84,7 +85,7 @@ sqlparse static3 tabulate uWSGI -Werkzeug<=2.0.3 +Werkzeug xlrd xlwt xlutils @@ -104,5 +105,12 @@ django-trench # Sentry sentry-sdk +# Kobocat +django-guardian +modilabs-python-utils +djangorestframework-csv +djangorestframework-jsonp +pandas + # Python 3.8 support backports.zoneinfo; python_version < '3.9' diff --git a/dependencies/pip/requirements.txt b/dependencies/pip/requirements.txt index c65aa00244..22a6b99dcd 100644 --- a/dependencies/pip/requirements.txt +++ b/dependencies/pip/requirements.txt @@ -4,102 +4,115 @@ # # pip-compile dependencies/pip/requirements.in # --e git+https://github.com/dimagi/django-digest@419f7306443f9a800b07d832b2cc147941062d59#egg=django_digest +-e git+https://github.com/kobotoolbox/django-digest@43f3100a7e257942c313ad79057e6a0b1612a74a#egg=django_digest # via -r dependencies/pip/requirements.in -e git+https://github.com/trevoriancox/django-dont-vary-on.git@01a804122b7ddcdc22f50b40993f91c27b03bef6#egg=django-dont-vary-on # via -r dependencies/pip/requirements.in --e git+https://github.com/kobotoolbox/formpack.git@3b6c89a00c77693a775ef91b68d6965678a7e4fe#egg=formpack +-e git+https://github.com/kobotoolbox/formpack.git@451df4cd2a0d614be69a3b3309259c67369f7efb#egg=formpack # via -r dependencies/pip/requirements.in --e git+https://github.com/kobotoolbox/kobo-service-account.git@871762cdd099ed543d36f0a29bfbff1de4766a8b#egg=kobo-service-account +-e git+https://github.com/kobotoolbox/kobo-service-account.git@cb52c6221b68af9b13237d0a1157e3f1965a82b1#egg=kobo-service-account # via -r dependencies/pip/requirements.in -e git+https://github.com/dimagi/python-digest@5c94bb74516b977b60180ee832765c0695ff2b56#egg=python_digest # via -r dependencies/pip/requirements.in -e git+https://github.com/kobotoolbox/ssrf-protect@9b97d3f0fd8f737a38dd7a6b64efeffc03ab3cdd#egg=ssrf_protect # via -r dependencies/pip/requirements.in -amqp==5.1.1 +aiohttp==3.9.3 + # via + # aiohttp-retry + # twilio +aiohttp-retry==2.8.3 + # via twilio +aiosignal==1.3.1 + # via aiohttp +amqp==5.2.0 # via # -r dependencies/pip/requirements.in # kombu -asgiref==3.5.0 - # via django -async-timeout==4.0.2 - # via redis -attrs==21.4.0 - # via jsonschema -azure-core==1.24.1 +asgiref==3.8.1 + # via + # django + # django-cors-headers +async-timeout==4.0.3 + # via + # aiohttp + # redis +attrs==23.2.0 + # via + # aiohttp + # jsonschema + # referencing +azure-core==1.30.1 # via # azure-storage-blob - # msrest -azure-storage-blob==12.12.0 + # django-storages +azure-storage-blob==12.19.1 # via django-storages begins==0.9 # via formpack -billiard==3.6.4.0 +billiard==4.2.0 # via # -r dependencies/pip/requirements.in # celery -boto3==1.22.2 +boto3==1.34.75 # via # django-amazon-ses # django-storages -botocore==1.25.2 +botocore==1.34.75 # via # boto3 # s3transfer -cachetools==5.2.0 +cachetools==5.3.3 # via google-auth -celery[redis]==5.2.6 +celery[redis]==5.3.6 # via # -r dependencies/pip/requirements.in # django-celery-beat # flower -certifi==2021.10.8 +certifi==2024.2.2 # via - # msrest # requests # sentry-sdk -cffi==1.15.0 +cffi==1.16.0 # via cryptography -charset-normalizer==2.0.12 +charset-normalizer==3.3.2 # via requests -click==8.1.2 +click==8.1.7 # via # celery # click-didyoumean # click-plugins # click-repl -click-didyoumean==0.3.0 +click-didyoumean==0.3.1 # via celery click-plugins==1.1.1 # via celery -click-repl==0.2.0 +click-repl==0.3.0 # via celery -cryptography==37.0.1 +cron-descriptor==1.4.3 + # via django-celery-beat +cryptography==42.0.5 # via # azure-storage-blob # jwcrypto # pyjwt # pyopenssl -cssselect==1.1.0 +cssselect==1.2.0 # via pyquery -deepmerge==1.0.1 +deepmerge==1.1.1 # via -r dependencies/pip/requirements.in defusedxml==0.7.1 # via + # -r dependencies/pip/requirements.in # djangorestframework-xml # python3-openid # pyxform -deprecated==1.2.13 - # via - # jwcrypto - # redis -dict2xml==1.7.1 +dict2xml==1.7.5 # via -r dependencies/pip/requirements.in dj-static==0.0.6 # via -r dependencies/pip/requirements.in -dj-stripe==2.7.3 +dj-stripe==2.8.3 # via -r dependencies/pip/requirements.in -django==3.2.15 +django==4.2.11 # via # -r dependencies/pip/requirements.in # dj-stripe @@ -113,6 +126,7 @@ django==3.2.15 # django-dont-vary-on # django-extensions # django-filter + # django-guardian # django-markdownx # django-oauth-toolkit # django-organizations @@ -125,167 +139,196 @@ django==3.2.15 # djangorestframework # jsonfield # kobo-service-account -django-allauth==0.57.0 +django-allauth==0.61.1 # via -r dependencies/pip/requirements.in django-amazon-ses==4.0.1 # via -r dependencies/pip/requirements.in django-braces==1.15.0 # via -r dependencies/pip/requirements.in -django-celery-beat==2.2.1 +django-celery-beat==2.6.0 # via -r dependencies/pip/requirements.in -django-constance[database]==2.9.1 +django-constance==3.1.0 # via -r dependencies/pip/requirements.in -django-cors-headers==3.11.0 +django-cors-headers==4.3.1 # via -r dependencies/pip/requirements.in -django-csp==3.7 +django-csp==3.8 # via -r dependencies/pip/requirements.in -django-debug-toolbar==3.2.4 +django-debug-toolbar==4.3.0 # via -r dependencies/pip/requirements.in -django-environ==0.8.1 +django-environ==0.11.2 # via -r dependencies/pip/requirements.in -django-extensions==3.1.5 +django-extensions==3.2.3 + # via + # -r dependencies/pip/requirements.in + # django-organizations +django-filter==24.2 # via -r dependencies/pip/requirements.in -django-filter==21.1 +django-guardian==2.4.0 # via -r dependencies/pip/requirements.in -django-loginas==0.3.10 +django-loginas==0.3.11 # via -r dependencies/pip/requirements.in -django-markdownx==4.0.2 +django-markdownx==4.0.7 # via -r dependencies/pip/requirements.in -django-oauth-toolkit==2.0.0 +django-oauth-toolkit==2.3.0 # via -r dependencies/pip/requirements.in -django-organizations==2.0.2 +django-organizations==2.4.1 # via -r dependencies/pip/requirements.in -django-picklefield==3.0.1 +django-picklefield==3.1 # via django-constance -django-private-storage==3.0 +django-private-storage==3.1.1 # via -r dependencies/pip/requirements.in -django-prometheus==2.2.0 +django-prometheus==2.3.1 # via -r dependencies/pip/requirements.in -django-redis==5.2.0 +django-redis==5.4.0 # via -r dependencies/pip/requirements.in django-redis-sessions==0.6.2 # via -r dependencies/pip/requirements.in django-request-cache==1.4.0 # via -r dependencies/pip/requirements.in -django-reversion==5.0.0 +django-reversion==5.0.12 # via -r dependencies/pip/requirements.in -django-storages[azure,boto3]==1.13.2 +django-storages[azure,boto3]==1.14.2 # via -r dependencies/pip/requirements.in -django-taggit==2.1.0 +django-taggit==5.0.1 # via -r dependencies/pip/requirements.in -django-timezone-field==4.2.3 +django-timezone-field==6.1.0 # via django-celery-beat django-trench==0.3.1 # via -r dependencies/pip/requirements.in -django-userforeignkey==0.4.0 +django-userforeignkey==0.5.0 # via django-request-cache -django-webpack-loader==2.0.1 +django-webpack-loader==3.0.1 # via -r dependencies/pip/requirements.in -djangorestframework==3.13.1 +djangorestframework==3.15.1 # via # -r dependencies/pip/requirements.in + # djangorestframework-csv # drf-extensions # kobo-service-account +djangorestframework-csv==3.0.2 + # via -r dependencies/pip/requirements.in +djangorestframework-jsonp==1.0.2 + # via -r dependencies/pip/requirements.in djangorestframework-xml==2.0.0 # via -r dependencies/pip/requirements.in -docutils==0.18.1 +dnspython==2.6.1 + # via pymongo +docutils==0.20.1 # via statistics drf-extensions==0.7.1 # via -r dependencies/pip/requirements.in et-xmlfile==1.1.0 # via openpyxl -flower==1.2.0 +flower==2.0.1 # via -r dependencies/pip/requirements.in -future==0.18.2 +frozenlist==1.4.1 + # via + # aiohttp + # aiosignal +future==1.0.0 # via -r dependencies/pip/requirements.in -geojson-rewind==1.0.2 +geojson-rewind==1.1.0 # via # -r dependencies/pip/requirements.in # formpack -google-api-core[grpc]==2.8.2 +google-api-core[grpc]==2.18.0 # via # google-api-python-client # google-cloud-core # google-cloud-speech # google-cloud-storage # google-cloud-translate -google-api-python-client==2.62.0 +google-api-python-client==2.124.0 # via -r dependencies/pip/requirements.in -google-auth==2.8.0 +google-auth==2.29.0 # via # google-api-core # google-api-python-client # google-auth-httplib2 # google-cloud-core + # google-cloud-speech # google-cloud-storage -google-auth-httplib2==0.1.0 + # google-cloud-translate +google-auth-httplib2==0.2.0 # via google-api-python-client -google-cloud-core==2.3.1 +google-cloud-core==2.4.1 # via # google-cloud-storage # google-cloud-translate -google-cloud-speech==2.14.1 +google-cloud-speech==2.25.1 # via -r dependencies/pip/requirements.in -google-cloud-storage==2.4.0 +google-cloud-storage==2.16.0 # via -r dependencies/pip/requirements.in -google-cloud-translate==3.7.4 +google-cloud-translate==3.15.3 # via -r dependencies/pip/requirements.in -google-crc32c==1.3.0 - # via google-resumable-media -google-resumable-media==2.3.3 +google-crc32c==1.5.0 + # via + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.0 # via google-cloud-storage -googleapis-common-protos==1.56.2 +googleapis-common-protos==1.63.0 # via # google-api-core # grpcio-status -grpcio==1.46.3 +grpcio==1.62.1 # via # google-api-core # grpcio-status -grpcio-status==1.46.3 +grpcio-status==1.62.1 # via google-api-core -httplib2==0.20.4 +httplib2==0.22.0 # via # google-api-python-client # google-auth-httplib2 -humanize==4.6.0 +humanize==4.9.0 # via flower -idna==3.3 - # via requests +idna==3.6 + # via + # requests + # yarl isodate==0.6.1 - # via msrest -jmespath==1.0.0 + # via azure-storage-blob +jmespath==1.0.1 # via # boto3 # botocore jsonfield==3.1.0 - # via - # -r dependencies/pip/requirements.in - # dj-stripe -jsonschema==4.4.0 + # via -r dependencies/pip/requirements.in +jsonschema==4.21.1 # via # -r dependencies/pip/requirements.in # formpack -jwcrypto==1.2 +jsonschema-specifications==2023.12.1 + # via jsonschema +jwcrypto==1.5.6 # via django-oauth-toolkit -kombu==5.2.4 +kombu==5.3.6 # via # -r dependencies/pip/requirements.in # celery -lxml==4.8.0 +lxml==5.2.0 # via # -r dependencies/pip/requirements.in # formpack # pyquery -markdown==3.3.6 +markdown==3.6 # via # -r dependencies/pip/requirements.in # django-markdownx -msrest==0.7.1 - # via azure-storage-blob +markupsafe==2.1.5 + # via werkzeug +modilabs-python-utils==0.1.5 + # via -r dependencies/pip/requirements.in +multidict==6.0.5 + # via + # aiohttp + # yarl ndg-httpsclient==0.5.1 # via -r dependencies/pip/requirements.in -oauthlib==3.2.0 +numpy==1.24.4 + # via pandas +oauthlib==3.2.2 # via # -r dependencies/pip/requirements.in # django-oauth-toolkit @@ -294,25 +337,26 @@ openpyxl==3.0.9 # via # -r dependencies/pip/requirements.in # pyxform -packaging==21.3 - # via redis -path==16.4.0 +pandas==2.0.3 + # via -r dependencies/pip/requirements.in +path==16.10.0 # via path-py path-py==12.5.0 # via formpack -pillow==9.1.0 +pillow==10.3.0 # via django-markdownx -prometheus-client==0.16.0 +prometheus-client==0.20.0 # via # django-prometheus # flower -prompt-toolkit==3.0.29 +prompt-toolkit==3.0.43 # via click-repl -proto-plus==1.20.6 +proto-plus==1.23.0 # via + # google-api-core # google-cloud-speech # google-cloud-translate -protobuf==3.20.1 +protobuf==4.25.3 # via # google-api-core # google-cloud-speech @@ -320,70 +364,70 @@ protobuf==3.20.1 # googleapis-common-protos # grpcio-status # proto-plus -psycopg2==2.9.3 +psycopg==3.1.18 # via -r dependencies/pip/requirements.in -pyasn1==0.4.8 +pyasn1==0.6.0 # via # -r dependencies/pip/requirements.in # ndg-httpsclient # pyasn1-modules # rsa -pyasn1-modules==0.2.8 +pyasn1-modules==0.4.0 # via google-auth -pycparser==2.21 +pycparser==2.22 # via cffi -pygments==2.12.0 +pygments==2.17.2 # via -r dependencies/pip/requirements.in -pyjwt[crypto]==2.3.0 +pyjwt[crypto]==2.8.0 # via # django-allauth # twilio -pymongo==3.12.3 +pymongo==4.6.3 # via -r dependencies/pip/requirements.in -pyopenssl==22.0.0 +pyopenssl==24.1.0 # via # -r dependencies/pip/requirements.in # ndg-httpsclient -pyotp==2.6.0 +pyotp==2.9.0 # via django-trench -pyparsing==3.0.8 - # via - # httplib2 - # packaging -pyquery==1.4.3 +pyparsing==3.1.2 + # via httplib2 +pyquery==2.0.0 # via formpack -pyrsistent==0.18.1 - # via jsonschema -python-crontab==2.6.0 +python-crontab==3.0.0 # via django-celery-beat -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 # via # -r dependencies/pip/requirements.in # botocore + # celery + # pandas # python-crontab python3-openid==3.2.0 # via django-allauth -pytz==2022.1 +pytz==2024.1 # via - # celery - # django - # django-timezone-field - # djangorestframework # flower - # twilio + # pandas pyxform==1.9.0 # via # -r dependencies/pip/requirements.in # formpack -redis==4.2.2 +pyyaml==6.0.1 + # via responses +redis==5.0.3 # via # celery # django-redis # django-redis-sessions # kobo-service-account -regex==2023.6.3 +referencing==0.34.0 + # via + # jsonschema + # jsonschema-specifications +regex==2023.12.25 # via -r dependencies/pip/requirements.in -requests==2.27.1 +requests==2.31.0 # via # -r dependencies/pip/requirements.in # azure-core @@ -391,40 +435,36 @@ requests==2.27.1 # django-oauth-toolkit # google-api-core # google-cloud-storage - # msrest # requests-oauthlib # responses # smsapi-client # stripe # twilio # yubico-client -requests-oauthlib==1.3.1 - # via - # django-allauth - # msrest -responses==0.20.0 +requests-oauthlib==2.0.0 + # via django-allauth +responses==0.25.0 # via -r dependencies/pip/requirements.in -rsa==4.8 +rpds-py==0.18.0 + # via + # jsonschema + # referencing +rsa==4.9 # via google-auth -s3transfer==0.5.2 +s3transfer==0.10.1 # via boto3 -sentry-sdk==1.5.12 +sentry-sdk==1.44.0 # via -r dependencies/pip/requirements.in -shortuuid==1.0.8 +shortuuid==1.0.13 # via -r dependencies/pip/requirements.in six==1.16.0 # via # azure-core - # click-repl - # django-organizations - # google-auth - # google-auth-httplib2 - # grpcio # isodate # python-dateutil -smsapi-client==2.6.0 +smsapi-client==2.9.5 # via django-trench -sqlparse==0.4.2 +sqlparse==0.4.4 # via # -r dependencies/pip/requirements.in # django @@ -435,43 +475,51 @@ static3==0.7.0 # dj-static statistics==1.0.3.5 # via formpack -stripe==4.1.0 +stripe==4.2.0 # via dj-stripe -tabulate==0.8.9 +tabulate==0.9.0 # via -r dependencies/pip/requirements.in -tornado==6.2 +tornado==6.4 # via flower -twilio==7.8.2 +twilio==9.0.3 # via django-trench -typing-extensions==4.2.0 - # via azure-core +typing-extensions==4.10.0 + # via + # asgiref + # azure-core + # azure-storage-blob + # jwcrypto + # psycopg +tzdata==2024.1 + # via + # celery + # django-celery-beat + # pandas uritemplate==4.1.1 # via google-api-python-client -urllib3==1.26.9 +urllib3==1.26.18 # via # botocore # requests # responses # sentry-sdk -uwsgi==2.0.21 +uwsgi==2.0.24 # via -r dependencies/pip/requirements.in -vine==5.0.0 +vine==5.1.0 # via # amqp # celery # kombu -wcwidth==0.2.5 +wcwidth==0.2.13 # via prompt-toolkit -werkzeug==2.0.3 +werkzeug==3.0.2 # via -r dependencies/pip/requirements.in -wrapt==1.14.0 - # via deprecated xlrd==2.0.1 # via # -r dependencies/pip/requirements.in # pyxform # xlutils -xlsxwriter==3.0.3 +xlsxwriter==3.2.0 # via # -r dependencies/pip/requirements.in # formpack @@ -481,6 +529,8 @@ xlwt==1.3.0 # via # -r dependencies/pip/requirements.in # xlutils +yarl==1.9.4 + # via aiohttp yubico-client==1.13.0 # via django-trench backports-zoneinfo==0.2.1; python_version < '3.9' diff --git a/docker/init.bash b/docker/entrypoint.sh similarity index 85% rename from docker/init.bash rename to docker/entrypoint.sh index bbab9a1a00..faa90c7204 100755 --- a/docker/init.bash +++ b/docker/entrypoint.sh @@ -1,6 +1,5 @@ #!/bin/bash set -e - source /etc/profile echo 'KPI initializing…' @@ -14,8 +13,8 @@ if [[ -z $DATABASE_URL ]]; then fi # Handle Python dependencies BEFORE attempting any `manage.py` commands -KPI_WEB_SERVER="${KPI_WEB_SERVER:-uWSGI}" -if [[ "${KPI_WEB_SERVER,,}" == 'uwsgi' ]]; then +WSGI="${WSGI:-uWSGI}" +if [[ "${WSGI}" == 'uWSGI' ]]; then # `diff` returns exit code 1 if it finds a difference between the files if ! diff -q "${KPI_SRC_DIR}/dependencies/pip/requirements.txt" "${TMP_DIR}/pip_dependencies.txt" then @@ -37,7 +36,7 @@ fi /bin/bash "${INIT_PATH}/wait_for_postgres.bash" echo 'Running migrations…' -gosu "${UWSGI_USER}" python manage.py migrate --noinput +gosu "${UWSGI_USER}" scripts/migrate.sh echo 'Creating superuser…' gosu "${UWSGI_USER}" python manage.py create_kobo_superuser @@ -77,16 +76,7 @@ if [[ ! -d "${KPI_SRC_DIR}/locale" ]] || [[ -z "$(ls -A ${KPI_SRC_DIR}/locale)" python manage.py compilemessages fi -rm -rf /etc/profile.d/pydev_debugger.bash.sh -if [[ -d /srv/pydev_orig && -n "${KPI_PATH_FROM_ECLIPSE_TO_PYTHON_PAIRS}" ]]; then - echo 'Enabling PyDev remote debugging.' - "${KPI_SRC_DIR}/docker/setup_pydev.bash" -fi - -echo 'Cleaning up Celery PIDs…' -rm -rf /tmp/celery*.pid - -echo 'Restore permissions on Celery logs folder' +echo 'Restore permissions on logs folder' chown -R "${UWSGI_USER}:${UWSGI_GROUP}" "${KPI_LOGS_DIR}" # This can take a while when starting a container with lots of media files. @@ -96,4 +86,12 @@ chown -R "${UWSGI_USER}:${UWSGI_GROUP}" "${KPI_MEDIA_DIR}" echo 'KPI initialization completed.' -exec /usr/bin/runsvdir "${SERVICES_DIR}" +cd "${KPI_SRC_DIR}" + +if [[ "${WSGI}" == 'uWSGI' ]]; then + echo "Running \`kpi\` container with uWSGI application server." + $(command -v uwsgi) --ini ${KPI_SRC_DIR}/docker/uwsgi.ini +else + echo "Running \`kpi\` container with \`runserver_plus\` debugging application server." + python manage.py runserver_plus 0:8000 +fi diff --git a/docker/run_celery_beat.bash b/docker/entrypoint_celery_beat.bash similarity index 99% rename from docker/run_celery_beat.bash rename to docker/entrypoint_celery_beat.bash index 27fb36b8d3..1b6d0829d6 100755 --- a/docker/run_celery_beat.bash +++ b/docker/entrypoint_celery_beat.bash @@ -4,6 +4,7 @@ source /etc/profile # Run the main Celery worker (will not process `sync_kobocat_xforms` jobs). cd "${KPI_SRC_DIR}" + exec celery -A kobo beat --loglevel=info \ --logfile=${KPI_LOGS_DIR}/celery_beat.log \ --pidfile=/tmp/celery_beat.pid \ diff --git a/docker/entrypoint_celery_kobocat_worker.bash b/docker/entrypoint_celery_kobocat_worker.bash new file mode 100755 index 0000000000..68e5c77a14 --- /dev/null +++ b/docker/entrypoint_celery_kobocat_worker.bash @@ -0,0 +1,20 @@ +#!/bin/bash +set -e +source /etc/profile + +# Run the main Celery worker (will NOT process low-priority jobs) + +cd "${KPI_SRC_DIR}" + +AUTOSCALE_MIN="${CELERY_AUTOSCALE_MIN:-2}" +AUTOSCALE_MAX="${CELERY_AUTOSCALE_MAX:-6}" + +exec celery -A kobo worker --loglevel=info \ + --hostname=kobocat_worker@%h \ + --logfile=${KPI_LOGS_DIR}/celery_kobocat_worker.log \ + --pidfile=/tmp/celery_kobocat_worker.pid \ + --queues=kobocat_queue \ + --exclude-queues=kpi_low_priority_queue,kpi_queue \ + --uid=${UWSGI_USER} \ + --gid=${UWSGI_GROUP} \ + --autoscale ${AUTOSCALE_MIN},${AUTOSCALE_MAX} diff --git a/docker/run_celery_low_priority.bash b/docker/entrypoint_celery_kpi_low_priority_worker.bash similarity index 64% rename from docker/run_celery_low_priority.bash rename to docker/entrypoint_celery_kpi_low_priority_worker.bash index 7c328a3a6c..de8323f627 100755 --- a/docker/run_celery_low_priority.bash +++ b/docker/entrypoint_celery_kpi_low_priority_worker.bash @@ -10,11 +10,11 @@ AUTOSCALE_MIN="${CELERY_AUTOSCALE_MIN:-2}" AUTOSCALE_MAX="${CELERY_AUTOSCALE_MAX:-6}" exec celery -A kobo worker --loglevel=info \ - --hostname=kpi_main_worker@%h \ - --logfile=${KPI_LOGS_DIR}/celery_low_priority.log \ - --pidfile=/tmp/celery_low_priority.pid \ + --hostname=kpi_low_priority_worker@%h \ + --logfile=${KPI_LOGS_DIR}/celery_kpi_low_priority_worker.log \ + --pidfile=/tmp/celery_kpi_low_priority_worker.pid \ --queues=kpi_low_priority_queue \ - --exclude-queues=kpi_queue \ + --exclude-queues=kpi_queue,kobocat_queue \ --uid=${UWSGI_USER} \ --gid=${UWSGI_GROUP} \ --autoscale ${AUTOSCALE_MIN},${AUTOSCALE_MAX} diff --git a/docker/run_celery.bash b/docker/entrypoint_celery_kpi_worker.bash similarity index 67% rename from docker/run_celery.bash rename to docker/entrypoint_celery_kpi_worker.bash index 15a8634f54..ba193c2b44 100755 --- a/docker/run_celery.bash +++ b/docker/entrypoint_celery_kpi_worker.bash @@ -10,11 +10,11 @@ AUTOSCALE_MIN="${CELERY_AUTOSCALE_MIN:-2}" AUTOSCALE_MAX="${CELERY_AUTOSCALE_MAX:-6}" exec celery -A kobo worker --loglevel=info \ - --hostname=kpi_main_worker@%h \ - --logfile=${KPI_LOGS_DIR}/celery.log \ - --pidfile=/tmp/celery.pid \ + --hostname=kpi_worker@%h \ + --logfile=${KPI_LOGS_DIR}/celery_kpi_worker.log \ + --pidfile=/tmp/celery_kpi_worker.pid \ --queues=kpi_queue \ - --exclude-queues=kpi_low_priority_queue \ + --exclude-queues=kpi_low_priority_queue,kobocat_queue \ --uid=${UWSGI_USER} \ --gid=${UWSGI_GROUP} \ --autoscale ${AUTOSCALE_MIN},${AUTOSCALE_MAX} diff --git a/docker/run_tests.bash b/docker/run_tests.bash deleted file mode 100755 index e03b24ceb8..0000000000 --- a/docker/run_tests.bash +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -e - -source /etc/profile - -pytest -npm run test diff --git a/docker/run_uwsgi.bash b/docker/run_uwsgi.bash deleted file mode 100755 index 13dc9dc9ef..0000000000 --- a/docker/run_uwsgi.bash +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -source /etc/profile - -KPI_WEB_SERVER="${KPI_WEB_SERVER:-uWSGI}" -UWSGI_COMMAND="$(command -v uwsgi) --ini ${KPI_SRC_DIR}/docker/uwsgi.ini" - -cd "${KPI_SRC_DIR}" -if [[ "${KPI_WEB_SERVER,,}" == 'uwsgi' ]]; then - echo "Running \`kpi\` container with uWSGI application server." - exec ${UWSGI_COMMAND} -else - echo "Running \`kpi\` container with \`runserver_plus\` debugging application server." - exec python manage.py runserver_plus 0:8000 -fi diff --git a/docker/setup_pydev.bash b/docker/setup_pydev.bash deleted file mode 100755 index 482517ba08..0000000000 --- a/docker/setup_pydev.bash +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -e - -if [[ ! -d /srv/pydev_orig ]]; then - echo 'Directory `/srv/pydev_orig` must exist to use PyDev debugger (see `kobo-docker/docker-compose.yml`).' - exit 1 -fi - -cp -a /srv/pydev_orig /srv/pydev - -if [[ -z "${KPI_PATH_FROM_ECLIPSE_TO_PYTHON_PAIRS}" ]]; then - echo '`KPI_PATH_FROM_ECLIPSE_TO_PYTHON_PAIRS` must be set to use the PyDev debugger (see `kobo-docker/envfiles/kpi.txt`).' - exit 1 -fi - -echo 'Setting up PyDev remote debugger path mappings.' - -# Set up the `PATHS_FROM_ECLIPSE_TO_PYTHON` variable from the environment per -# https://github.com/fabioz/PyDev.Debugger/blob/master/pydevd_file_utils.py. -find_string='PATHS_FROM_ECLIPSE_TO_PYTHON = []' -replace_string="\ -import os\n\ -path_map_pair_strings = os.environ['KPI_PATH_FROM_ECLIPSE_TO_PYTHON_PAIRS'].split('|')\n\ -PATHS_FROM_ECLIPSE_TO_PYTHON = [tuple([pair_element.strip() for pair_element in pair_string.split('->')]) for pair_string in path_map_pair_strings]\n\ -" - -escaped_find_sting="$(echo "${find_string}" | sed -e 's/[]\/$*.^|[]/\\&/g')" -escaped_replace_string=$(echo "${replace_string}" | sed -e '/\\n/b; s/[]\/$*.^|[]/\\&/g') - -sed -i "s/${escaped_find_sting}/${escaped_replace_string}/" /srv/pydev/pydevd_file_utils.py - -echo 'Adding `PYTHONPATH` modifications to profile.' -echo 'export PYTHONPATH=${PYTHONPATH}:/srv/pydev' > /etc/profile.d/pydev_debugger.bash.sh diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index 5f2e139f54..6ab4bae557 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -13,23 +13,23 @@ mount = $(KPI_PREFIX)=$(KPI_SRC_DIR)/kobo/wsgi.py # process related settings master = true -harakiri = $(KPI_UWSGI_HARAKIRI) -worker-reload-mercy = $(KPI_UWSGI_WORKER_RELOAD_MERCY) +harakiri = $(UWSGI_HARAKIRI) +worker-reload-mercy = $(UWSGI_WORKER_RELOAD_MERCY) # monitoring (use with `uwsgitop :1717`, for example) stats = :1717 memory-report = true # Overrideable default of 2 uWSGI processes. -if-env = KPI_UWSGI_WORKERS_COUNT +if-env = UWSGI_WORKERS_COUNT workers = %(_) endif = -if-not-env = KPI_UWSGI_WORKERS_COUNT +if-not-env = UWSGI_WORKERS_COUNT workers = 2 endif = # activate cheaper mode -if-env = KPI_UWSGI_CHEAPER_WORKERS_COUNT +if-env = UWSGI_CHEAPER_WORKERS_COUNT cheaper-algo = busyness cheaper = %(_) cheaper-initial = %(_) @@ -41,23 +41,23 @@ cheaper-busyness-multiplier = 20 endif = # stop spawning new workers if total memory consumption grows too large -if-env = KPI_UWSGI_CHEAPER_RSS_LIMIT_SOFT +if-env = UWSGI_CHEAPER_RSS_LIMIT_SOFT cheaper-rss-limit-soft = %(_) endif = -if-not-env = KPI_UWSGI_CHEAPER_RSS_LIMIT_SOFT +if-not-env = UWSGI_CHEAPER_RSS_LIMIT_SOFT cheaper-rss-limit-soft = %(2 * 1024 * 1024 * 1024) endif = # respawn processes after serving KPI_UWSGI_MAX_REQUESTS requests (default 5000) -if-env = KPI_UWSGI_MAX_REQUESTS +if-env = UWSGI_MAX_REQUESTS max-requests = %(_) endif = # respawn workers when their memory consumption grows too large -if-env = KPI_UWSGI_RELOAD_ON_RSS_MB +if-env = UWSGI_RELOAD_ON_RSS_MB reload-on-rss = %(_) endif = -if-not-env = KPI_UWSGI_RELOAD_ON_RSS_MB +if-not-env = UWSGI_RELOAD_ON_RSS_MB reload-on-rss = 512 endif = diff --git a/hub/admin/__init__.py b/hub/admin/__init__.py index 255551b842..59131c27e3 100644 --- a/hub/admin/__init__.py +++ b/hub/admin/__init__.py @@ -1,6 +1,7 @@ from django.contrib import admin -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import Group +from kobo.apps.kobo_auth.shortcuts import User from .extra_user_detail import ExtraUserDetailAdmin from .extend_user import ExtendedUserAdmin from .password_validation import PasswordValidationAdmin @@ -18,6 +19,5 @@ admin.site.register(ConfigurationFile) admin.site.register(PerUserSetting) admin.site.register(PasswordValidation, PasswordValidationAdmin) -admin.site.unregister(User) admin.site.unregister(Group) admin.site.register(User, ExtendedUserAdmin) diff --git a/hub/admin/extend_user.py b/hub/admin/extend_user.py index 05f303ce10..ce8a0d602e 100644 --- a/hub/admin/extend_user.py +++ b/hub/admin/extend_user.py @@ -15,6 +15,7 @@ from django.utils import timezone from django.utils.safestring import mark_safe +from kobo.apps.accounts.mfa.models import MfaMethod from kobo.apps.accounts.validators import ( USERNAME_MAX_LENGTH, USERNAME_INVALID_MESSAGE, @@ -32,6 +33,17 @@ from .mixins import AdvancedSearchMixin +def validate_superuser_auth(obj) -> bool: + if ( + obj.is_superuser + and config.SUPERUSER_AUTH_ENFORCEMENT + and obj.has_usable_password() + and not MfaMethod.objects.filter(user=obj, is_active=True).exists() + ): + return False + return True + + class UserChangeForm(DjangoUserChangeForm): username = CharField( @@ -53,6 +65,10 @@ def clean(self): f'User is in trash and cannot be reactivated' f' from here.' )) + if cleaned_data.get('is_superuser', False) and not validate_superuser_auth(self.instance): + raise ValidationError( + "Superusers with a usable password must enable MFA." + ) return cleaned_data diff --git a/hub/admin/password_validation.py b/hub/admin/password_validation.py index b7d3a95752..bf8e67e15d 100644 --- a/hub/admin/password_validation.py +++ b/hub/admin/password_validation.py @@ -3,7 +3,7 @@ from django.db import transaction from django.utils.html import format_html -from kpi.deployment_backends.kc_access.shadow_models import KobocatUserProfile +from kobo.apps.openrosa.apps.main.models import UserProfile from .filters import PasswordValidationAdvancedSearchFilter from .mixins import AdvancedSearchMixin from ..models import ExtraUserDetail @@ -67,12 +67,17 @@ def get_queryset(self, request): @admin.display(description='Validated') def get_validated_password(self, obj): - value = True + value = False try: value = obj.extra_details.validated_password except obj.extra_details.RelatedObjectDoesNotExist: pass + try: + value = value and obj.profile.validated_password + except obj.profile.RelatedObjectDoesNotExist: + pass + return format_html( '{}', 'yes' if value else 'no', @@ -88,8 +93,14 @@ def invalidate_passwords(self, request, queryset, **kwargs): ExtraUserDetail.objects.filter(user_id__in=user_ids).update( validated_password=False ) - KobocatUserProfile.objects.filter(user_id__in=user_ids).update( - validated_password=False + UserProfile.objects.bulk_create( + [ + UserProfile(user_id=user_id, validated_password=False) + for user_id in user_ids + ], + update_conflicts=True, + unique_fields=['user_id'], + update_fields=['validated_password'], ) self.message_user( @@ -107,8 +118,14 @@ def validate_passwords(self, request, queryset, **kwargs): ExtraUserDetail.objects.filter(user_id__in=user_ids).update( validated_password=True ) - KobocatUserProfile.objects.filter(user_id__in=user_ids).update( - validated_password=True + UserProfile.objects.bulk_create( + [ + UserProfile(user_id=user_id, validated_password=True) + for user_id in user_ids + ], + update_conflicts=True, + unique_fields=['user_id'], + update_fields=['validated_password'], ) self.message_user( diff --git a/hub/migrations/0003_auto_20160318_1808.py b/hub/migrations/0003_auto_20160318_1808.py index 7b1e2548ea..9b68f7a252 100644 --- a/hub/migrations/0003_auto_20160318_1808.py +++ b/hub/migrations/0003_auto_20160318_1808.py @@ -6,7 +6,7 @@ def create_extrauserdetails(apps, schema_editor): ExtraUserDetail = apps.get_model('hub', 'ExtraUserDetail') - User = apps.get_model('auth', 'User') + User = apps.get_model('kobo_auth', 'User') for user in User.objects.all(): ExtraUserDetail.objects.get_or_create(user=user) diff --git a/hub/models/configuration_file.py b/hub/models/configuration_file.py index 4df579dddf..2162a9ea04 100644 --- a/hub/models/configuration_file.py +++ b/hub/models/configuration_file.py @@ -61,7 +61,7 @@ def content_view(cls, request, slug): mtime = content.storage.get_modified_time(content.name).timestamp() if not was_modified_since( - request.META.get('HTTP_IF_MODIFIED_SINCE'), mtime, size + request.META.get('HTTP_IF_MODIFIED_SINCE'), mtime ): return HttpResponseNotModified() diff --git a/hub/models/password_validation.py b/hub/models/password_validation.py index 0793bcc3f4..fabd66532b 100644 --- a/hub/models/password_validation.py +++ b/hub/models/password_validation.py @@ -9,5 +9,5 @@ class PasswordValidation(User): class Meta: proxy = True - app_label = 'auth' # hack to make it appear in the same action as Users + app_label = 'kobo_auth' # hack to make it appear in the same action as Users verbose_name_plural = 'Password validation' diff --git a/hub/models/per_user_setting.py b/hub/models/per_user_setting.py index 7086a06feb..ca7d41d2e2 100644 --- a/hub/models/per_user_setting.py +++ b/hub/models/per_user_setting.py @@ -1,7 +1,7 @@ -from django.contrib.auth.models import User from django.core.exceptions import FieldError, ValidationError from django.db import models +from kobo.apps.kobo_auth.shortcuts import User from kpi.utils.object_permission import get_database_user diff --git a/hub/templates/admin/password_validation_advanced_search_filter.html b/hub/templates/admin/password_validation_advanced_search_filter.html index 481d17a5b3..3736f962a7 100644 --- a/hub/templates/admin/password_validation_advanced_search_filter.html +++ b/hub/templates/admin/password_validation_advanced_search_filter.html @@ -18,7 +18,7 @@

Advanced query search

AND date_joined__date__lt:2023-03-01
  • Same as above, but more accurate version with time: -
    extra_details__last_password_date_changed__lt:2023-03-01T12:00:00
    +
    extra_details__password_date_changed__lt:2023-03-01T12:00:00
    AND date_joined__date__lt:2023-03-01:2023-03-01T12:00:00
  • Fields:
  • diff --git a/hub/tests/test_admin_validators.py b/hub/tests/test_admin_validators.py new file mode 100644 index 0000000000..0b185a8ab0 --- /dev/null +++ b/hub/tests/test_admin_validators.py @@ -0,0 +1,26 @@ +from constance.test import override_config +from django.test import TestCase + +from hub.admin.extend_user import validate_superuser_auth +from kobo.apps.accounts.mfa.models import MfaMethod +from kobo.apps.kobo_auth.shortcuts import User + + +@override_config(SUPERUSER_AUTH_ENFORCEMENT=True) +class ValidateSuperuserMfaTest(TestCase): + + def setUp(self): + self.superuser = User.objects.create_superuser( + username='admin', password='adminpassword' + ) + + def test_superuser_without_mfa_and_usable_password(self): + self.assertFalse(validate_superuser_auth(self.superuser)) + + def test_superuser_with_unusable_password(self): + self.superuser.set_unusable_password() + self.assertTrue(validate_superuser_auth(self.superuser)) + + def test_superuser_with_mfa_enabled(self): + MfaMethod.objects.create(user=self.superuser, is_active=True) + self.assertTrue(validate_superuser_auth(self.superuser)) diff --git a/hub/tests/test_globalsettings.py b/hub/tests/test_globalsettings.py index d96c4ae44e..6165c92e26 100644 --- a/hub/tests/test_globalsettings.py +++ b/hub/tests/test_globalsettings.py @@ -3,8 +3,10 @@ from constance.test import override_config from django.urls import reverse from django.test import TestCase +from django.test import override_settings +@override_settings(STRIPE_ENABLED=False) class GlobalSettingsTestCase(TestCase): fixtures = ['test_data'] diff --git a/hub/tests/test_i18n.py b/hub/tests/test_i18n.py index ee61bd04ae..5bd2bfcddf 100644 --- a/hub/tests/test_i18n.py +++ b/hub/tests/test_i18n.py @@ -26,7 +26,7 @@ def test_welcome_message(self): self.assertEqual(welcome_message, 'Global welcome message') self.assertEqual(welcome_message_es, welcome_message) - # TODO validate whethere the tests below are necessary. + # TODO validate whether the tests below are necessary. # Kinda redundant with kobo/apps/accounts/tests/test_forms.py::AccountFormsTestCase @override_config(USER_METADATA_FIELDS=LazyJSONSerializable([ { diff --git a/hub/tests/test_perusersetting.py b/hub/tests/test_perusersetting.py index 3b295e528b..bd11b0594e 100644 --- a/hub/tests/test_perusersetting.py +++ b/hub/tests/test_perusersetting.py @@ -1,10 +1,9 @@ # coding: utf-8 -from django.contrib.auth.models import User, AnonymousUser -from django.urls import reverse +from django.contrib.auth.models import AnonymousUser from django.test import TestCase from hub.models import PerUserSetting -from kpi.utils.strings import to_str +from kobo.apps.kobo_auth.shortcuts import User class PerUserSettingTestCase(TestCase): diff --git a/hub/tests/test_user_details.py b/hub/tests/test_user_details.py index c7443c1aa0..9399d81aad 100644 --- a/hub/tests/test_user_details.py +++ b/hub/tests/test_user_details.py @@ -1,6 +1,7 @@ # coding: utf-8 from django.test import TestCase -from django.contrib.auth.models import User + +from kobo.apps.kobo_auth.shortcuts import User class UserDetailTestCase(TestCase): diff --git a/jsapp/js/account/accountFieldsEditor.module.scss b/jsapp/js/account/accountFieldsEditor.module.scss index 19b84111de..d7fc67d957 100644 --- a/jsapp/js/account/accountFieldsEditor.module.scss +++ b/jsapp/js/account/accountFieldsEditor.module.scss @@ -1,6 +1,6 @@ @use 'scss/sizes'; @use 'js/components/common/textBox.module'; -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; .row { display: flex; diff --git a/jsapp/js/account/accountSettings.scss b/jsapp/js/account/accountSettings.scss index c79fef751a..4a63d9c828 100644 --- a/jsapp/js/account/accountSettings.scss +++ b/jsapp/js/account/accountSettings.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; @use 'scss/libs/_mdl'; @@ -52,23 +52,6 @@ font-size: 16px; } } - - .form-modal__item--anonymous-submission-notice { - display: flex; - line-height: sizes.$x24; - background-color: colors.$kobo-light-amber; - margin-top: sizes.$x12; - padding: sizes.$x18; - border-radius: sizes.$x8; - - .anonymous-submission-notice-copy { - padding-left: sizes.$x16; - padding-right: sizes.$x8; - > * { - display: inline; - } - } - } } .account-settings__actions { @@ -79,10 +62,6 @@ .account-settings-save:last-child { margin-right: sizes.$x50; } - - .account-settings-close { - padding: 0 12px; - } } .form-modal__item { @@ -116,3 +95,7 @@ .account-settings-social-row:not(:first-child) { margin-top: sizes.$x5; } + +.anonymous-submission-notice { + margin-top: 12px; +} diff --git a/jsapp/js/account/accountSettingsRoute.tsx b/jsapp/js/account/accountSettingsRoute.tsx index f267dff2e2..4594141490 100644 --- a/jsapp/js/account/accountSettingsRoute.tsx +++ b/jsapp/js/account/accountSettingsRoute.tsx @@ -1,5 +1,6 @@ import React, {useEffect, useState} from 'react'; import Button from 'js/components/common/button'; +import InlineMessage from 'js/components/common/inlineMessage'; import {observer} from 'mobx-react'; import type {Form} from 'react-router-dom'; import {unstable_usePrompt as usePrompt} from 'react-router-dom'; @@ -142,7 +143,7 @@ const AccountSettings = observer(() => { + /> diff --git a/jsapp/js/account/organizations/requireOrgOwner.component.tsx b/jsapp/js/account/organizations/requireOrgOwner.component.tsx new file mode 100644 index 0000000000..927bfe23df --- /dev/null +++ b/jsapp/js/account/organizations/requireOrgOwner.component.tsx @@ -0,0 +1,32 @@ +import React, {Suspense, useContext, useEffect} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {OrganizationContext} from 'js/account/organizations/useOrganization.hook'; +import LoadingSpinner from 'js/components/common/loadingSpinner'; +import {ACCOUNT_ROUTES} from 'js/account/routes.constants'; + +interface Props { + children: React.ReactNode; + redirect?: boolean; +} + +export const RequireOrgOwner = ({children, redirect = true}: Props) => { + const [organization, _, orgStatus] = useContext(OrganizationContext); + const navigate = useNavigate(); + + useEffect(() => { + if ( + redirect && + !orgStatus.pending && + organization && + !organization.is_owner + ) { + navigate(ACCOUNT_ROUTES.ACCOUNT_SETTINGS); + } + }, [organization, orgStatus.pending, redirect]); + + return redirect && organization?.is_owner ? ( + {children} + ) : ( + + ); +}; diff --git a/jsapp/js/account/organizations/useOrganization.hook.tsx b/jsapp/js/account/organizations/useOrganization.hook.tsx new file mode 100644 index 0000000000..2a36fa55f0 --- /dev/null +++ b/jsapp/js/account/organizations/useOrganization.hook.tsx @@ -0,0 +1,26 @@ +import React, {createContext} from 'react'; +import {getOrganization} from 'js/account/stripe.api'; +import type {Organization} from 'js/account/stripe.types'; +import {useApiFetcher, withApiFetcher} from 'js/hooks/useApiFetcher.hook'; + +const loadOrganization = async () => { + const response = await getOrganization(); + return response?.results?.[0]; +}; + +const INITIAL_ORGANIZATION_STATE: Organization = Object.freeze({ + id: '', + name: '', + is_active: false, + created: '', + modified: '', + slug: '', + is_owner: false, +}); + +export const useOrganization = () => + useApiFetcher(loadOrganization, INITIAL_ORGANIZATION_STATE); + +export const OrganizationContext = createContext( + withApiFetcher(INITIAL_ORGANIZATION_STATE) +); diff --git a/jsapp/js/account/plans/addOnList.module.scss b/jsapp/js/account/plans/addOnList.module.scss deleted file mode 100644 index 0dfd24c8c2..0000000000 --- a/jsapp/js/account/plans/addOnList.module.scss +++ /dev/null @@ -1,65 +0,0 @@ -@use '~kobo-common/src/styles/colors'; -@use 'scss/breakpoints'; -@use 'scss/sizes'; - -.header { - font-size: sizes.$r18; - font-weight: 700; - color: colors.$kobo-storm; - text-transform: uppercase; -} - -.caption { - text-align: start; -} - -.table { - table-layout: fixed; - border-collapse: collapse; - border-spacing: 0; - - & tr { - border-top: 1px solid colors.$kobo-light-storm; - - &:last-child { - border-bottom: 1px solid colors.$kobo-light-storm; - } - } - - & td { - padding-block: 1em; - font-weight: 700; - overflow: hidden; - } -} - -.product, -.price { - width: 40%; -} - -.buy { - width: max(20%, 40em); -} - -@media screen and (min-width: breakpoints.$b1140) { - .table { - padding: 1em; - } - - .product { - width: 65%; - } - - .price { - width: auto; - } - - .buy { - width: 15%; - } - - .table td:nth-child(2) { - margin-inline-end: 10em; - } -} diff --git a/jsapp/js/account/plans/billingButton.component.tsx b/jsapp/js/account/plans/billingButton.component.tsx index ae74fd377b..7ae766e852 100644 --- a/jsapp/js/account/plans/billingButton.component.tsx +++ b/jsapp/js/account/plans/billingButton.component.tsx @@ -1,7 +1,8 @@ +import cx from 'classnames'; import type {ButtonProps} from 'js/components/common/button'; import Button from 'js/components/common/button'; import React from 'react'; -import {button} from './billingButton.module.scss'; +import styles from './billingButton.module.scss'; /** * The base button component that's used on the Plans/Add-ons pages. @@ -15,7 +16,8 @@ export default function BillingButton(props: Partial) { color='blue' size='l' {...props} - classNames={props.classNames ? [button, ...props.classNames] : [button]} + className={cx([styles.button, props.className])} + isFullWidth /> ); } diff --git a/jsapp/js/account/plans/confirmChangeModal.component.tsx b/jsapp/js/account/plans/confirmChangeModal.component.tsx index f19ea30a4f..cfcabc8c2f 100644 --- a/jsapp/js/account/plans/confirmChangeModal.component.tsx +++ b/jsapp/js/account/plans/confirmChangeModal.component.tsx @@ -7,7 +7,8 @@ import KoboModalHeader from 'js/components/modals/koboModalHeader'; import KoboModalContent from 'js/components/modals/koboModalContent'; import KoboModalFooter from 'js/components/modals/koboModalFooter'; import type { - BasePrice, + Price, + PriceWithProduct, Product, SubscriptionInfo, } from 'js/account/stripe.types'; @@ -17,18 +18,21 @@ import { isAddonProduct, processChangePlanResponse, } from 'js/account/stripe.utils'; -import {formatDate} from 'js/utils'; +import {formatDate, notify} from 'js/utils'; import styles from './confirmChangeModal.module.scss'; import BillingButton from 'js/account/plans/billingButton.component'; +import {useDisplayPrice} from 'js/account/plans/useDisplayPrice.hook'; export interface ConfirmChangeProps { - newPrice: BasePrice | null; + newPrice: Price | null; products: Product[] | null; + quantity?: number; currentSubscription: SubscriptionInfo | null; } interface ConfirmChangeModalProps extends ConfirmChangeProps { onRequestClose: () => void; + setIsBusy: (isBusy: boolean) => void; } /** @@ -41,9 +45,12 @@ const ConfirmChangeModal = ({ products, currentSubscription, onRequestClose, + setIsBusy, + quantity = 1, }: ConfirmChangeModalProps) => { const [isLoading, setIsLoading] = useState(false); const [pendingChange, setPendingChange] = useState(false); + const displayPrice = useDisplayPrice(newPrice, quantity); const shouldShow = useMemo( () => !!(currentSubscription && newPrice), @@ -62,7 +69,7 @@ const ConfirmChangeModal = ({ // get a translatable description of a newPrice const getPriceDescription = useCallback( - (price: BasePrice) => { + (price: Price) => { const product = getProductForPriceId(price.id); if (price && product) { if (isAddonProduct(product)) { @@ -90,9 +97,12 @@ const ConfirmChangeModal = ({ // get the product type to display as a translatable string const getPriceType = useCallback( - (price: BasePrice) => { + (price: PriceWithProduct | null) => { + if (!price) { + return t('plan'); + } const product = getProductForPriceId(price.id); - if (price && product) { + if (product) { if (isAddonProduct(product)) { return t('add-on'); } else { @@ -110,22 +120,36 @@ const ConfirmChangeModal = ({ } }, [shouldShow && pendingChange]); + const onClickCancel = () => { + onRequestClose(); + setIsBusy(false); + }; + const submitChange = () => { if (isLoading || !newPrice || !currentSubscription) { return; } setIsLoading(true); setPendingChange(true); - changeSubscription(newPrice.id, currentSubscription.id) + changeSubscription(newPrice.id, currentSubscription.id, quantity) .then((data) => { - processChangePlanResponse(data).then((status) => { - if (status !== ChangePlanStatus.success) { - onRequestClose(); - } - }); + processChangePlanResponse(data); + setPendingChange(false); + onClickCancel(); }) - .catch(onRequestClose) - .finally(() => setPendingChange(false)); + .catch(() => { + notify.error( + t( + 'There was an error processing your plan change. Your previous plan has not been changed. Please try again later.' + ), + { + duration: 10000, + } + ); + setIsBusy(false); + setPendingChange(false); + onClickCancel(); + }); }; return ( @@ -156,7 +180,7 @@ const ConfirmChangeModal = ({ ) + ' '} {t( `Your current ##product_type## will remain in effect until ##billing_end_date##. - Starting on ##billing_end_date## and until you cancel, we will bill you ##new_price## per ##interval##.` + Starting on ##billing_end_date## and until you cancel, we will bill you ##new_price##.` ) .replace( /##product_type##/g, @@ -166,11 +190,7 @@ const ConfirmChangeModal = ({ /##billing_end_date##/g, formatDate(currentSubscription.current_period_end) ) - .replace( - '##new_price##', - newPrice.human_readable_price.split('/')[0] - ) - .replace('##interval##', newPrice.recurring.interval)} + .replace('##new_price##', displayPrice)}

    )} @@ -182,9 +202,9 @@ const ConfirmChangeModal = ({ label={t('Submit')} /> diff --git a/jsapp/js/account/plans/plan.component.tsx b/jsapp/js/account/plans/plan.component.tsx index 8905e228fa..3703971f76 100644 --- a/jsapp/js/account/plans/plan.component.tsx +++ b/jsapp/js/account/plans/plan.component.tsx @@ -1,5 +1,6 @@ import React, { useCallback, + useContext, useEffect, useMemo, useReducer, @@ -8,12 +9,7 @@ import React, { } from 'react'; import {useNavigate, useSearchParams} from 'react-router-dom'; import styles from './plan.module.scss'; -import { - getOrganization, - getProducts, - postCheckout, - postCustomerPortal, -} from '../stripe.api'; +import {postCheckout, postCustomerPortal} from '../stripe.api'; import Button from 'js/components/common/button'; import classnames from 'classnames'; import LoadingSpinner from 'js/components/common/loadingSpinner'; @@ -21,43 +17,43 @@ import {notify} from 'js/utils'; import {ACTIVE_STRIPE_STATUSES} from 'js/constants'; import type {FreeTierThresholds} from 'js/envStore'; import envStore from 'js/envStore'; -import {ACCOUNT_ROUTES} from 'js/account/routes'; import useWhen from 'js/hooks/useWhen.hook'; -import AddOnList from 'js/account/plans/addOnList.component'; +import AddOnList from 'js/account/add-ons/addOnList.component'; import subscriptionStore from 'js/account/subscriptionStore'; import {when} from 'mobx'; import { getSubscriptionsForProductId, + isDowngrade, processCheckoutResponse, } from 'js/account/stripe.utils'; import type { - BasePrice, - Organization, Price, + Organization, Product, SubscriptionInfo, + SinglePricedProduct, } from 'js/account/stripe.types'; import type {ConfirmChangeProps} from 'js/account/plans/confirmChangeModal.component'; import ConfirmChangeModal from 'js/account/plans/confirmChangeModal.component'; -import Session from 'js/stores/session'; -import InlineMessage from 'js/components/common/inlineMessage'; import {PlanContainer} from 'js/account/plans/planContainer.component'; +import {ProductsContext} from '../useProducts.hook'; +import {OrganizationContext} from 'js/account/organizations/useOrganization.hook'; +import {ACCOUNT_ROUTES} from 'js/account/routes.constants'; +import {useRefreshApiFetcher} from 'js/hooks/useRefreshApiFetcher.hook'; export interface PlanState { subscribedProduct: null | SubscriptionInfo[]; intervalFilter: string; filterToggle: boolean; - products: null | Product[]; - organization: null | Organization; featureTypes: string[]; } +interface PlanProps { + showAddOns?: boolean; +} + // An interface for our action type DataUpdates = - | { - type: 'initialProd'; - prodData: Product[]; - } | { type: 'initialSub'; prodData: SubscriptionInfo[]; @@ -79,8 +75,6 @@ const initialState: PlanState = { subscribedProduct: null, intervalFilter: 'month', filterToggle: true, - products: null, - organization: null, featureTypes: ['advanced', 'support', 'addons'], }; @@ -88,10 +82,6 @@ const subscriptionUpgradeMessageDuration = 8000; function planReducer(state: PlanState, action: DataUpdates): PlanState { switch (action.type) { - case 'initialProd': - return {...state, products: action.prodData}; - case 'initialOrg': - return {...state, organization: action.prodData}; case 'initialSub': return {...state, subscribedProduct: action.prodData}; case 'month': @@ -111,7 +101,7 @@ function planReducer(state: PlanState, action: DataUpdates): PlanState { } } -export default function Plan() { +export default function Plan(props: PlanProps) { // useReducer type defs incorrectly require an initializer arg - see https://github.com/facebook/react/issues/27052 const [state, dispatch]: [PlanState, (arg: DataUpdates) => void] = useReducer( planReducer, @@ -123,29 +113,47 @@ export default function Plan() { const [activeSubscriptions, setActiveSubscriptions] = useState< SubscriptionInfo[] >([]); + const [products, loadProducts, productsStatus] = useContext(ProductsContext); + useRefreshApiFetcher(loadProducts, productsStatus); + const [organization, loadOrg, orgStatus] = useContext(OrganizationContext); + useRefreshApiFetcher(loadOrg, orgStatus); const [confirmModal, setConfirmModal] = useState({ newPrice: null, products: [], currentSubscription: null, + quantity: 1, }); const [visiblePlanTypes, setVisiblePlanTypes] = useState(['default']); - const [session, setSession] = useState(() => Session); - const [isUnauthorized, setIsUnauthorized] = useState(false); const [searchParams] = useSearchParams(); const didMount = useRef(false); const navigate = useNavigate(); + const [showGoTop, setShowGoTop] = useState(false); + const pageBody = useRef(null); + + const handleVisibleButton = () => { + if (pageBody.current && pageBody.current.scrollTop > 300) { + setShowGoTop(true); + } else { + setShowGoTop(false); + } + }; + + const handleScrollUp = () => { + pageBody.current?.scrollTo({left: 0, top: 0, behavior: 'smooth'}); + }; + + useEffect(() => { + pageBody.current?.addEventListener('scroll', handleVisibleButton); + }, []); const isDataLoading = useMemo( (): boolean => - !(state.products && state.organization && state.subscribedProduct), - [state.products, state.organization, state.subscribedProduct] + !(products.isLoaded && organization && state.subscribedProduct), + [products.isLoaded, organization, state.subscribedProduct] ); - const isDisabled = useMemo( - () => isBusy || isUnauthorized, - [isBusy, isUnauthorized] - ); + const isDisabled = useMemo(() => isBusy, [isBusy]); const hasManageableStatus = useCallback( (subscription: SubscriptionInfo) => @@ -200,53 +208,29 @@ export default function Plan() { navigate(ACCOUNT_ROUTES.ACCOUNT_SETTINGS); return; } - const fetchPromises = []; - if (!subscriptionStore.isInitialised || !subscriptionStore.isPending) { + if (!subscriptionStore.isInitialised) { subscriptionStore.fetchSubscriptionInfo(); } - fetchPromises[0] = getProducts().then((data) => { - // If we have no products, redirect - if (!data.count) { - navigate(ACCOUNT_ROUTES.ACCOUNT_SETTINGS); - } + when(() => subscriptionStore.isInitialised).then(() => { dispatch({ - type: 'initialProd', - prodData: data.results, + type: 'initialSub', + prodData: subscriptionStore.planResponse, }); - }); - fetchPromises[1] = getOrganization().then((data) => { - dispatch({ - type: 'initialOrg', - prodData: data.results[0], - }); - }); - fetchPromises[2] = when(() => subscriptionStore.isInitialised).then( - () => { - dispatch({ - type: 'initialSub', - prodData: subscriptionStore.planResponse, - }); - setActiveSubscriptions(subscriptionStore.activeSubscriptions); - } - ); - Promise.all(fetchPromises).then(() => { + setActiveSubscriptions(subscriptionStore.activeSubscriptions); setIsBusy(false); }); }, [searchParams, shouldRevalidate] ); - // we need to show a message and disable the page if the user is not the owner of their org + // if the user is not the owner of their org, send them back to the settings page useEffect(() => { - if ( - state.organization && - state.organization.owner_username !== session.currentAccount.username - ) { - setIsUnauthorized(true); + if (!organization?.is_owner) { + navigate(ACCOUNT_ROUTES.ACCOUNT_SETTINGS); } - }, [state.organization]); + }, [organization]); // Re-fetch data from API and re-enable buttons if displaying from back/forward cache useEffect(() => { @@ -324,39 +308,41 @@ export default function Plan() { ); // An array of all the prices that should be displayed in the UI - const filterPrices = useMemo((): Price[] => { - if (state.products !== null) { - const filterAmount = state.products.map((product: Product): Price => { - const filteredPrices = product.prices.filter((price: BasePrice) => { - const interval = price.recurring?.interval; - return ( - // only show monthly/annual plans based on toggle value - interval === state.intervalFilter && - // don't show recurring add-ons - product.metadata.product_type === 'plan' && - // only show products that don't have a `plan_type` or those that match the `?type=` query param - (visiblePlanTypes.includes(product.metadata?.plan_type) || - (!product.metadata?.plan_type && - visiblePlanTypes.includes('default'))) - ); - }); + const filteredPriceProducts = useMemo((): SinglePricedProduct[] => { + if (products.products.length) { + const filterAmount = products.products.map( + (product: Product): SinglePricedProduct => { + const filteredPrices = product.prices.filter((price: Price) => { + const interval = price.recurring?.interval; + return ( + // only show monthly/annual plans based on toggle value + interval === state.intervalFilter && + // don't show recurring add-ons + product.metadata.product_type === 'plan' && + // only show products that don't have a `plan_type` or those that match the `?type=` query param + (visiblePlanTypes.includes(product.metadata?.plan_type || '') || + (!product.metadata?.plan_type && + visiblePlanTypes.includes('default'))) + ); + }); - return { - ...product, - prices: filteredPrices[0], - }; - }); + return { + ...product, + price: filteredPrices[0], + }; + } + ); - return filterAmount.filter((price) => price.prices); + return filterAmount.filter((price) => price.price); } return []; - }, [state.products, state.intervalFilter, visiblePlanTypes]); + }, [products.products, state.intervalFilter, visiblePlanTypes]); const getSubscribedProduct = useCallback(getSubscriptionsForProductId, []); const isSubscribedProduct = useCallback( - (product: Price) => { - if (!product.prices?.unit_amount && !hasActiveSubscription) { + (product: SinglePricedProduct, quantity: number | undefined) => { + if (!product.price?.unit_amount && !hasActiveSubscription) { return true; } @@ -368,13 +354,15 @@ export default function Plan() { if (subscriptions && subscriptions.length > 0) { return subscriptions.some( (subscription: SubscriptionInfo) => - subscription.items[0].price.id === product.prices.id && - hasManageableStatus(subscription) + subscription.items[0].price.id === product.price.id && + hasManageableStatus(subscription) && + quantity !== undefined && + quantity === subscription.quantity ); } return false; }, - [state.subscribedProduct, state.intervalFilter, state.products] + [state.subscribedProduct, state.intervalFilter, products.products] ); const dismissConfirmModal = () => { @@ -383,32 +371,31 @@ export default function Plan() { }); }; - const buySubscription = (price: BasePrice) => { - if (!price.id || isDisabled || !state.organization?.id) { + const buySubscription = (price: Price, quantity = 1) => { + if (!price.id || isDisabled || !organization?.id) { return; } setIsBusy(true); if (activeSubscriptions.length) { - if ( - activeSubscriptions[0].items?.[0].price.unit_amount < price.unit_amount - ) { + if (!isDowngrade(activeSubscriptions, price, quantity)) { // if the user is upgrading prices, send them to the customer portal // this will immediately change their subscription - postCustomerPortal(state.organization.id, price.id) + postCustomerPortal(organization.id, price.id, quantity) .then(processCheckoutResponse) .catch(() => setIsBusy(false)); } else { // if the user is downgrading prices, open a confirmation dialog and downgrade from kpi // this will downgrade the subscription at the end of the current billing period setConfirmModal({ - products: state.products, + products: products.products, newPrice: price, currentSubscription: activeSubscriptions[0], + quantity: quantity, }); } } else { // just send the user to the checkout page - postCheckout(price.id, state.organization.id) + postCheckout(price.id, organization.id, quantity) .then(processCheckoutResponse) .catch(() => setIsBusy(false)); } @@ -416,9 +403,9 @@ export default function Plan() { const hasMetaFeatures = () => { let expandBool = false; - if (state.products && state.products.length > 0) { - filterPrices.map((price) => { - for (const featureItem in price.metadata) { + if (products.products.length) { + filteredPriceProducts.map((product) => { + for (const featureItem in product.metadata) { if ( featureItem.includes('feature_support_') || featureItem.includes('feature_advanced_') || @@ -433,46 +420,47 @@ export default function Plan() { return expandBool; }; - const getFeatureMetadata = (price: Price, featureItem: string) => { - if ( - price.prices.unit_amount === 0 && - freeTierOverride && - freeTierOverride.hasOwnProperty(featureItem) - ) { - return freeTierOverride[featureItem as keyof FreeTierOverride]; - } - return price.prices.metadata?.[featureItem] || price.metadata[featureItem]; - }; - useEffect(() => { hasMetaFeatures(); - }, [state.products]); + }, [products.products]); + + const comparisonButton = () => + hasMetaFeatures() && ( +
    +
    + ); - if (!state.products?.length || !state.organization) { + if (!products.products.length || !organization) { return null; } + if (isDataLoading) { + return ; + } + return ( <> - {isDataLoading ? ( - - ) : ( - <> - {isUnauthorized && ( - - )} -
    +
    + {!props.showAddOns && ( + <>
    - {filterPrices.map((price: Price) => ( -
    + {filteredPriceProducts.map((product: SinglePricedProduct) => ( +
    ))} +
    + {comparisonButton()} +
    {shouldShowExtras && (
    @@ -557,45 +549,30 @@ export default function Plan() {
    )}
    +
    {comparisonButton()}
    - - {hasMetaFeatures() && ( -
    -
    - )} - {shouldShowExtras && ( - - )} - -
    - - )} + + )} + {props.showAddOns && ( + + )} + {showGoTop && ( + + )} + +
    ); } diff --git a/jsapp/js/account/plans/plan.module.scss b/jsapp/js/account/plans/plan.module.scss index 3b7d03ef83..0259b714e2 100644 --- a/jsapp/js/account/plans/plan.module.scss +++ b/jsapp/js/account/plans/plan.module.scss @@ -1,9 +1,14 @@ @use 'scss/sizes'; -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/breakpoints'; @use 'sass:color'; @use 'scss/_variables'; +$plans-page-card-width: 320px; +$plans-page-gap: 20px; +$plans-page-max-width: $plans-page-card-width * 3 + $plans-page-gap * 2; +$plan-badge-height: 38px; + .accountPlan { padding: sizes.$x30 sizes.$x40; overflow-y: auto; @@ -12,7 +17,11 @@ width: 100%; display: flex; flex-direction: column; - row-gap: sizes.$x20; + row-gap: $plans-page-gap; +} + +.accountPlan.showAddOns { + padding: sizes.$x15 0 0 0; } .wait { @@ -30,15 +39,16 @@ } .allPlans { - column-gap: sizes.$x20; display: flex; - flex-direction: row; + flex-direction: column; + row-gap: $plans-page-gap; } .stripePlans { - flex: 1 1 0; + flex: 0 0 $plans-page-card-width; display: flex; flex-direction: column; + margin-top: 0; } .intervalToggle { @@ -86,7 +96,7 @@ } &:hover { - background-color: color.adjust(colors.$kobo-blue, $lightness: -5%); + background-color: color.adjust(colors.$kobo-blue, $lightness: -5%); } } @@ -95,63 +105,40 @@ border-radius: sizes.$x4; } -.planContainer { - border-radius: sizes.$x6; - border: colors.$kobo-gray-85 solid sizes.$x1; - padding: sizes.$x24; - flex-grow: 1; -} - .plansSection { display: flex; flex-direction: column; - row-gap: sizes.$x20; + row-gap: $plans-page-gap; + align-items: center; } .priceName, .enterpriseTitle { color: colors.$kobo-gray-40; - font-weight: 700; + font-weight: 600; font-size: sizes.$x24; line-height: sizes.$x32; margin: 0; padding: 0 0 sizes.$x12 0; } -.priceName { - text-align: left; -} - .priceTitle { color: colors.$kobo-dark-blue; - text-align: left; - font-size: sizes.$x18; + font-size: sizes.$x20; padding-bottom: sizes.$x16; - font-weight: 600; + font-weight: 700; line-height: sizes.$x20; height: 2em; } -.planContainer :global .k-button { - margin: sizes.$x24 auto 0; - width: 100%; - height: sizes.$x38; -} - -.planContainer :global span.k-button__label { - text-align: center; - width: 100%; -} - -$plan-badge-height: sizes.$x38; - -.planContainer:not(.planContainerWithBadge) { - margin-top: $plan-badge-height; -} - .planContainer { + border-radius: sizes.$x6; + border: colors.$kobo-gray-85 solid sizes.$x1; + padding: sizes.$x24; + width: $plans-page-card-width; display: flex; flex-direction: column; + height: 100%; // So it stretches to match the height of other containers } .planContainer.planContainerWithBadge { @@ -159,7 +146,7 @@ $plan-badge-height: sizes.$x38; } .featureContainer { - height: 18em; + height: 16em; } .planContainer :global hr { @@ -183,23 +170,29 @@ $plan-badge-height: sizes.$x38; margin-right: sizes.$x12; } +.selectableFeature { + display: inline-flex; + align-items: center; + gap: sizes.$x6; + margin-top: -(sizes.$x10); +} + .enterprisePlanContainer { - flex: 1; + flex: 0 0 $plans-page-card-width; display: flex; flex-direction: column; - margin-top: sizes.$x38; } .enterprisePlan { background-color: colors.$kobo-bg-blue; padding: sizes.$x24; - overflow: hidden; border-radius: sizes.$x6; - flex-grow: 1; + width: $plans-page-card-width; + height: 100%; } .enterpriseDetails { - line-height: sizes.$x24; + line-height: sizes.$x22; } a.enterpriseLink { @@ -211,7 +204,7 @@ a.enterpriseLink { .listTitle { color: colors.$kobo-gray-40; font-size: sizes.$x14; - font-weight: 700; + font-weight: 600; padding: 0; } @@ -219,24 +212,7 @@ a.enterpriseLink { margin: sizes.$x5 0 0; } -.expandedContainer > :nth-child(2) { - height: 11.2em; - margin-bottom: sizes.$x24; -} - -.expandedContainer > :nth-child(3) { - height: 7em; - margin-bottom: sizes.$x24; -} - -.expandedContainer > :nth-child(4) { - height: 7.5em; -} - -.planContainer :last-child { - margin-bottom: 0; -} - +// This is the badge on top of the card .currentPlan { background-color: colors.$kobo-storm; color: colors.$kobo-white; @@ -246,32 +222,84 @@ a.enterpriseLink { position: relative; top: 0; border-radius: sizes.$x6 sizes.$x6 0 0; - height: sizes.$x38; - width: 100%; + height: $plan-badge-height; + width: $plans-page-card-width; + font-weight: 700; + font-size: sizes.$x16; } -@media screen and (max-width: breakpoints.$b1440) { - .enterprisePlan { - font-size: sizes.$x12; +.comparisonButton { + margin-left: auto; + margin-right: auto; + width: $plans-page-card-width; + text-align: center; +} + +.planButton { + display: flex; + flex-direction: column; + justify-content: flex-end; + + &:not(:empty) { + margin-top: 30px; } +} - .planContainer { - font-size: sizes.$x12; +.scrollToTopButton{ + position: fixed; + bottom: sizes.$x20; + right: sizes.$x20; + border-radius: sizes.$x6; + background-color: colors.$kobo-gray-40; + width: sizes.$x50; + height: sizes.$x40; + color: colors.$kobo-white; + border: none; + cursor: pointer; + box-shadow: 0 sizes.$x4 sizes.$x12 0 rgba(0, 0, 0, 0.2); +} + +.maximizedCards { + width: 100%; + display: none; +} + +@include breakpoints.breakpoint(mediumAndUp) { + .allPlans { + column-gap: $plans-page-gap; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + align-items: stretch; + max-width: $plans-page-max-width; } - .iconContainer { - margin-right: sizes.$x10; + .planContainer:not(.planContainerWithBadge) { + margin-top: $plan-badge-height; } - .featureContainer { - height: 26em; + .enterprisePlanContainer { + margin-top: $plan-badge-height; } - .priceTitle { - height: 2.5em; + .expandedContainer > :nth-child(2) { + min-height: sizes.$x200; } .expandedContainer > :nth-child(4) { - height: 10.5em; + min-height: sizes.$x120; + } + + .comparisonButton { + width: 100%; + max-width: $plans-page-max-width; + } + + .minimizedCards { + display: none; + } + + .maximizedCards { + display: unset; } } diff --git a/jsapp/js/account/plans/planButton.component.tsx b/jsapp/js/account/plans/planButton.component.tsx index b1aac0b094..657d182dd9 100644 --- a/jsapp/js/account/plans/planButton.component.tsx +++ b/jsapp/js/account/plans/planButton.component.tsx @@ -1,17 +1,22 @@ import BillingButton from 'js/account/plans/billingButton.component'; -import React from 'react'; -import type {BasePrice, Organization, Price} from 'js/account/stripe.types'; +import React, {useContext} from 'react'; +import type { + Price, + Organization, + SinglePricedProduct, +} from 'js/account/stripe.types'; import {postCustomerPortal} from 'js/account/stripe.api'; import {processCheckoutResponse} from 'js/account/stripe.utils'; +import {OrganizationContext} from 'js/account/organizations/useOrganization.hook'; interface PlanButtonProps { - buySubscription: (price: BasePrice) => void; + buySubscription: (price: Price, quantity?: number) => void; downgrading: boolean; isBusy: boolean; isSubscribedToPlan: boolean; showManage: boolean; - organization?: Organization | null; - price: Price; + product: SinglePricedProduct; + quantity: number; setIsBusy: (value: boolean) => void; } @@ -20,22 +25,24 @@ interface PlanButtonProps { * Plans need extra logic that add-ons don't, mostly to display the correct label text. */ export const PlanButton = ({ - price, - organization, + product, downgrading, isBusy, setIsBusy, buySubscription, showManage, + quantity, isSubscribedToPlan, }: PlanButtonProps) => { - if (!price || !organization || price.prices.unit_amount === 0) { + const [organization] = useContext(OrganizationContext); + + if (!product || !organization || product.price.unit_amount === 0) { return null; } - const manageSubscription = (subscriptionPrice?: BasePrice) => { + const manageSubscription = (subscriptionPrice?: Price) => { setIsBusy(true); - postCustomerPortal(organization.id, subscriptionPrice?.id) + postCustomerPortal(organization.id, subscriptionPrice?.id, quantity) .then(processCheckoutResponse) .catch(() => setIsBusy(false)); }; @@ -44,8 +51,8 @@ export const PlanButton = ({ return ( buySubscription(price.prices)} - aria-label={`upgrade to ${price.name}`} + onClick={() => buySubscription(product.price, quantity)} + aria-label={`upgrade to ${product.name}`} isDisabled={isBusy} /> ); @@ -56,7 +63,7 @@ export const PlanButton = ({ ); @@ -65,8 +72,8 @@ export const PlanButton = ({ return ( buySubscription(price.prices)} - aria-label={`change your subscription to ${price.name}`} + onClick={() => buySubscription(product.price, quantity)} + aria-label={`change your subscription to ${product.name}`} isDisabled={isBusy} /> ); diff --git a/jsapp/js/account/plans/planContainer.component.tsx b/jsapp/js/account/plans/planContainer.component.tsx index 8a19b3a838..dd51ae5f9f 100644 --- a/jsapp/js/account/plans/planContainer.component.tsx +++ b/jsapp/js/account/plans/planContainer.component.tsx @@ -1,39 +1,46 @@ import classnames from 'classnames'; import styles from 'js/account/plans/plan.module.scss'; -import {PriceDisplay} from 'js/account/plans/priceDisplay.component'; import Icon from 'js/components/common/icon'; import {PlanButton} from 'js/account/plans/planButton.component'; -import React, {useCallback, useState} from 'react'; -import {BasePrice, Price, SubscriptionInfo} from 'js/account/stripe.types'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import { + SinglePricedProduct, + Price, + SubscriptionInfo, +} from 'js/account/stripe.types'; import {FreeTierOverride, PlanState} from 'js/account/plans/plan.component'; import { + getAdjustedQuantityForPrice, getSubscriptionsForProductId, isChangeScheduled, + isDowngrade, } from 'js/account/stripe.utils'; -import TextBox from 'js/components/common/textBox'; - -const MAX_SUBMISSION_PURCHASE = 10000000; +import KoboSelect, {KoboSelectOption} from 'js/components/common/koboSelect'; +import {useDisplayPrice} from 'js/account/plans/useDisplayPrice.hook'; interface PlanContainerProps { - price: Price; + product: SinglePricedProduct; isDisabled: boolean; - isSubscribedProduct: (product: Price) => boolean; + isSubscribedProduct: ( + product: SinglePricedProduct, + quantity: number + ) => boolean; freeTierOverride: FreeTierOverride | null; expandComparison: boolean; state: PlanState; - filterPrices: Price[]; + filteredPriceProducts: SinglePricedProduct[]; setIsBusy: (isBusy: boolean) => void; hasManageableStatus: (sub: SubscriptionInfo) => boolean; - buySubscription: (price: BasePrice) => void; + buySubscription: (price: Price, quantity?: number) => void; activeSubscriptions: SubscriptionInfo[]; } export const PlanContainer = ({ - price, + product, state, freeTierOverride, expandComparison, - filterPrices, + filteredPriceProducts, isDisabled, setIsBusy, hasManageableStatus, @@ -42,9 +49,10 @@ export const PlanContainer = ({ activeSubscriptions, }: PlanContainerProps) => { const [submissionQuantity, setSubmissionQuantity] = useState(1); - const [error, setError] = useState(''); + // display price for the plan/price/quantity we're currently displaying + const displayPrice = useDisplayPrice(product.price, submissionQuantity); const shouldShowManage = useCallback( - (product: Price) => { + (product: SinglePricedProduct) => { const subscriptions = getSubscriptionsForProductId( product.id, state.subscribedProduct @@ -60,20 +68,64 @@ export const PlanContainer = ({ return false; } - return isChangeScheduled(product.prices, [activeSubscription]); + return isChangeScheduled(product.price, [activeSubscription]); }, [hasManageableStatus, state.subscribedProduct] ); - const getFeatureMetadata = (price: Price, featureItem: string) => { + const isDowngrading = useMemo( + () => isDowngrade(activeSubscriptions, product.price, submissionQuantity), + [activeSubscriptions, product, submissionQuantity] + ); + + // The adjusted quantity is the number we multiply the price by to get the total price + const adjustedQuantity = useMemo(() => { + return getAdjustedQuantityForPrice( + submissionQuantity, + product.price.transform_quantity + ); + }, [product, submissionQuantity]); + + // Populate submission dropdown with the submission quantity from the customer's plan + // Default to this price's base submission quantity, if applicable + useEffect(() => { + const subscribedQuantity = + activeSubscriptions.length && activeSubscriptions?.[0].items[0].quantity; if ( - price.prices.unit_amount === 0 && + subscribedQuantity && + isSubscribedProduct(product, subscribedQuantity) + ) { + setSubmissionQuantity(subscribedQuantity); + } else if ( + // if there's no active subscription, check if this price has a default quantity + product.price.transform_quantity && + Boolean( + Number(product.metadata?.submission_limit) || + Number(product.price.metadata?.submission_limit) + ) + ) { + // prioritize the submission limit from the price over the submission limit from the product + setSubmissionQuantity( + parseInt(product.price.metadata.submission_limit) || + parseInt(product.metadata.submission_limit) + ); + } + }, [isSubscribedProduct, activeSubscriptions, product]); + + const getFeatureMetadata = ( + product: SinglePricedProduct, + featureItem: string + ) => { + if ( + product.price.unit_amount === 0 && freeTierOverride && freeTierOverride.hasOwnProperty(featureItem) ) { return freeTierOverride[featureItem as keyof FreeTierOverride]; } - return price.prices.metadata?.[featureItem] || price.metadata[featureItem]; + return ( + product.price.metadata?.[featureItem] || product.metadata[featureItem] + ); }; const renderFeaturesList = ( @@ -109,8 +161,8 @@ export const PlanContainer = ({ // Get feature items and matching icon boolean const getListItem = (listType: string, plan: string) => { const listItems: Array<{icon: boolean; item: string}> = []; - filterPrices.map((price) => - Object.keys(price.metadata).map((featureItem: string) => { + filteredPriceProducts.map((product) => + Object.keys(product.metadata).map((featureItem: string) => { const numberItem = featureItem.lastIndexOf('_'); const currentResult = featureItem.substring(numberItem + 1); @@ -118,14 +170,14 @@ export const PlanContainer = ({ if ( featureItem.includes(`feature_${listType}_`) && !featureItem.includes(`feature_${listType}_check`) && - price.name === plan + product.name === plan ) { const keyName = `feature_${listType}_${currentResult}`; let iconBool = false; const itemName: string = - price.prices.metadata?.[keyName] || price.metadata[keyName]; - if (price.metadata[currentIcon] !== undefined) { - iconBool = JSON.parse(price.metadata[currentIcon]); + product.price.metadata?.[keyName] || product.metadata[keyName]; + if (product.metadata?.[currentIcon] !== undefined) { + iconBool = JSON.parse(product.metadata[currentIcon]); listItems.push({icon: iconBool, item: itemName}); } } @@ -151,67 +203,138 @@ export const PlanContainer = ({ return renderFeaturesList(items, featureTitle); }; - const onSubmissionsChange = (value: number) => { - if (value) { - setSubmissionQuantity(value); - } - if (value > MAX_SUBMISSION_PURCHASE) { - setError( - t( - 'This plan only supports up to ##submissions## submissions per month. If your project needs more than that, please contact us about our Private Server options.' - ).replace('##submissions##', MAX_SUBMISSION_PURCHASE.toLocaleString()) - ); - } else { - if (error.length) { - setError(''); + const submissionOptions = useMemo((): KoboSelectOption[] => { + const options = []; + const submissionsPerUnit = + product.price.metadata?.submission_limit || + product.metadata?.submission_limit; + const maxPlanQuantity = parseInt( + product.price.metadata?.max_purchase_quantity || '1' + ); + if (submissionsPerUnit) { + for (let i = 1; i <= maxPlanQuantity; i++) { + const submissionCount = parseInt(submissionsPerUnit) * i; + options.push({ + label: '##submissions## submissions /month'.replace( + '##submissions##', + submissionCount.toLocaleString() + ), + value: submissionCount.toString(), + }); } } + return options; + }, [product]); + + const onSubmissionsChange = (value: string | null) => { + if (value === null) { + return; + } + const submissions = parseInt(value); + if (submissions) { + setSubmissionQuantity(submissions); + } }; + const asrMinutes = useMemo(() => { + return ( + (adjustedQuantity * + (parseInt(product.metadata?.nlp_seconds_limit || '0') || + parseInt(product.price.metadata?.nlp_seconds_limit || '0'))) / + 60 + ); + }, [adjustedQuantity, product]); + + const mtCharacters = useMemo(() => { + return ( + adjustedQuantity * + (parseInt(product.metadata?.nlp_character_limit || '0') || + parseInt(product.price.metadata?.nlp_character_limit || '0')) + ); + }, [adjustedQuantity, product]); + return ( <> - {isSubscribedProduct(price) ? ( + {isSubscribedProduct(product, submissionQuantity) ? (
    {t('Your plan')}
    - ) : ( -
    - )} + ) : null}

    - {price.prices?.unit_amount - ? price.name - : freeTierOverride?.name || price.name} + {product.price?.unit_amount + ? product.name + : freeTierOverride?.name || product.name}

    - - {price.prices.transform_quantity && ( - - )} +
    {displayPrice}
      - {Object.keys(price.metadata).map( + {product.price.transform_quantity && ( + <> +
    • + + +
    • +
    • +
      + +
      + {t( + '##asr_minutes## minutes of automated transcription /##plan_interval##' + ) + .replace('##asr_minutes##', asrMinutes.toLocaleString()) + .replace( + '##plan_interval##', + product.price.recurring!.interval + )} +
    • +
    • +
      + +
      + {t( + '##mt_characters## characters of machine translation /##plan_interval##' + ) + .replace('##mt_characters##', mtCharacters.toLocaleString()) + .replace( + '##plan_interval##', + product.price.recurring!.interval + )} +
    • + + )} + {Object.keys(product.metadata).map( (featureItem: string) => featureItem.includes('feature_list_') && ( -
    • +
    • - {getFeatureMetadata(price, featureItem)} + {getFeatureMetadata(product, featureItem)}
    • ) )} @@ -220,13 +343,13 @@ export const PlanContainer = ({

      {state.featureTypes.map((type, index, array) => { - const featureItem = getListItem(type, price.name); + const featureItem = getListItem(type, product.name); return ( featureItem.length > 0 && [ returnListItem( type, - price.name, - price.metadata[`feature_${type}_title`] + product.name, + product.metadata[`feature_${type}_title`] ), index !== array.length - 1 &&
      , ] @@ -234,20 +357,21 @@ export const PlanContainer = ({ })}
      )} - 0 && - activeSubscriptions?.[0].items?.[0].price.unit_amount > - price.prices.unit_amount - } - isSubscribedToPlan={isSubscribedProduct(price)} - buySubscription={buySubscription} - showManage={shouldShowManage(price)} - isBusy={isDisabled} - setIsBusy={setIsBusy} - organization={state.organization} - /> +
      + +
    ); diff --git a/jsapp/js/account/plans/priceDisplay.component.tsx b/jsapp/js/account/plans/priceDisplay.component.tsx deleted file mode 100644 index c15c63d61c..0000000000 --- a/jsapp/js/account/plans/priceDisplay.component.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import styles from 'js/account/plans/plan.module.scss'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {BasePrice} from 'js/account/stripe.types'; - -interface PriceDisplayProps { - price: BasePrice; - submissionQuantity: number; -} - -export const PriceDisplay = ({ - price, - submissionQuantity, -}: PriceDisplayProps) => { - const priceDisplay = useMemo(() => { - if (!price?.unit_amount) { - return t('Free'); - } - let totalPrice = 1; - if (price.transform_quantity?.divide_by) { - totalPrice = - (totalPrice * submissionQuantity) / price.transform_quantity.divide_by; - } - if (price.transform_quantity?.round === 'up') { - totalPrice = Math.ceil(totalPrice); - } - if (price.transform_quantity?.round === 'down') { - totalPrice = Math.floor(totalPrice); - } - if (price?.recurring?.interval === 'year') { - totalPrice /= 12; - } - totalPrice *= price.unit_amount / 100; - return t('$##price## USD/month').replace( - '##price##', - totalPrice.toFixed(2) - ); - }, [submissionQuantity, price]); - - return
    {priceDisplay}
    ; -}; diff --git a/jsapp/js/account/plans/useDisplayPrice.hook.tsx b/jsapp/js/account/plans/useDisplayPrice.hook.tsx new file mode 100644 index 0000000000..04b1b42089 --- /dev/null +++ b/jsapp/js/account/plans/useDisplayPrice.hook.tsx @@ -0,0 +1,26 @@ +import {useMemo} from 'react'; +import {Price} from 'js/account/stripe.types'; +import {getAdjustedQuantityForPrice} from 'js/account/stripe.utils'; + +export const useDisplayPrice = ( + price?: Price | null, + submissionQuantity = 1 +) => { + return useMemo(() => { + if (!price?.unit_amount) { + return t('Free'); + } + let totalPrice = price.unit_amount / 100; + if (price?.recurring?.interval === 'year') { + totalPrice /= 12; + } + totalPrice *= getAdjustedQuantityForPrice( + submissionQuantity, + price.transform_quantity + ); + return t('$##price## USD/month').replace( + '##price##', + totalPrice.toFixed(2) + ); + }, [submissionQuantity, price]); +}; diff --git a/jsapp/js/account/routes.constants.ts b/jsapp/js/account/routes.constants.ts new file mode 100644 index 0000000000..ad69498331 --- /dev/null +++ b/jsapp/js/account/routes.constants.ts @@ -0,0 +1,29 @@ +import React from 'react'; +import {ROUTES} from 'js/router/routerConstants'; + +export const ChangePasswordRoute = React.lazy( + () => import(/* webpackPrefetch: true */ './changePasswordRoute.component') +); +export const SecurityRoute = React.lazy( + () => import(/* webpackPrefetch: true */ './security/securityRoute.component') +); +export const PlansRoute = React.lazy( + () => import(/* webpackPrefetch: true */ './plans/plan.component') +); +export const AddOnsRoute = React.lazy( + () => import(/* webpackPrefetch: true */ './add-ons/addOns.component') +); +export const AccountSettings = React.lazy( + () => import(/* webpackPrefetch: true */ './accountSettingsRoute') +); +export const DataStorage = React.lazy( + () => import(/* webpackPrefetch: true */ './usage/usageTopTabs') +); +export const ACCOUNT_ROUTES: {readonly [key: string]: string} = { + ACCOUNT_SETTINGS: ROUTES.ACCOUNT_ROOT + '/settings', + USAGE: ROUTES.ACCOUNT_ROOT + '/usage', + SECURITY: ROUTES.ACCOUNT_ROOT + '/security', + PLAN: ROUTES.ACCOUNT_ROOT + '/plan', + ADD_ONS: ROUTES.ACCOUNT_ROOT + '/addons', + CHANGE_PASSWORD: ROUTES.ACCOUNT_ROOT + '/change-password', +}; diff --git a/jsapp/js/account/routes.tsx b/jsapp/js/account/routes.tsx index c92075ae98..63d11d7c58 100644 --- a/jsapp/js/account/routes.tsx +++ b/jsapp/js/account/routes.tsx @@ -1,31 +1,16 @@ import React from 'react'; import {Navigate, Route} from 'react-router-dom'; import RequireAuth from 'js/router/requireAuth'; -import {ROUTES} from 'js/router/routerConstants'; - -const ChangePasswordRoute = React.lazy( - () => import(/* webpackPrefetch: true */ './changePasswordRoute.component') -); -const SecurityRoute = React.lazy( - () => import(/* webpackPrefetch: true */ './security/securityRoute.component') -); -const PlanRoute = React.lazy( - () => import(/* webpackPrefetch: true */ './plans/plan.component') -); -const AccountSettings = React.lazy( - () => import(/* webpackPrefetch: true */ './accountSettingsRoute') -); -const DataStorage = React.lazy( - () => import(/* webpackPrefetch: true */ './usage/usage.component') -); - -export const ACCOUNT_ROUTES: {readonly [key: string]: string} = { - ACCOUNT_SETTINGS: ROUTES.ACCOUNT_ROOT + '/settings', - USAGE: ROUTES.ACCOUNT_ROOT + '/usage', - SECURITY: ROUTES.ACCOUNT_ROOT + '/security', - PLAN: ROUTES.ACCOUNT_ROOT + '/plan', - CHANGE_PASSWORD: ROUTES.ACCOUNT_ROOT + '/change-password', -}; +import {RequireOrgOwner} from 'js/account/organizations/requireOrgOwner.component'; +import { + ACCOUNT_ROUTES, + AccountSettings, + AddOnsRoute, + ChangePasswordRoute, + DataStorage, + PlansRoute, + SecurityRoute, +} from 'js/account/routes.constants'; export default function routes() { return ( @@ -44,17 +29,42 @@ export default function routes() { /> + + + + + } + /> + - + + + } /> + + + + + } + /> + - + } /> diff --git a/jsapp/js/account/security/apiToken/apiTokenSection.module.scss b/jsapp/js/account/security/apiToken/apiTokenSection.module.scss index b03ca48b1c..b2892c7904 100644 --- a/jsapp/js/account/security/apiToken/apiTokenSection.module.scss +++ b/jsapp/js/account/security/apiToken/apiTokenSection.module.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; @use 'scss/libs/_mdl'; diff --git a/jsapp/js/account/security/email/emailSection.component.tsx b/jsapp/js/account/security/email/emailSection.component.tsx index f7dc9bc1f2..8d4b4b7099 100644 --- a/jsapp/js/account/security/email/emailSection.component.tsx +++ b/jsapp/js/account/security/email/emailSection.component.tsx @@ -140,7 +140,7 @@ export default function EmailSection() {
    diff --git a/jsapp/js/account/security/email/emailSection.module.scss b/jsapp/js/account/security/email/emailSection.module.scss index a22ee69119..032303af50 100644 --- a/jsapp/js/account/security/email/emailSection.module.scss +++ b/jsapp/js/account/security/email/emailSection.module.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; @use 'scss/libs/_mdl'; diff --git a/jsapp/js/account/security/mfa/mfaSection.component.tsx b/jsapp/js/account/security/mfa/mfaSection.component.tsx index c06ae0d2a2..aa5629172e 100644 --- a/jsapp/js/account/security/mfa/mfaSection.component.tsx +++ b/jsapp/js/account/security/mfa/mfaSection.component.tsx @@ -5,7 +5,6 @@ import ToggleSwitch from 'js/components/common/toggleSwitch'; import Icon from 'js/components/common/icon'; import InlineMessage from 'js/components/common/inlineMessage'; import LoadingSpinner from 'js/components/common/loadingSpinner'; -import {stores} from 'js/stores'; import type { MfaUserMethodsResponse, MfaActivatedResponse, @@ -15,6 +14,7 @@ import {MODAL_TYPES} from 'jsapp/js/constants'; import envStore from 'js/envStore'; import './mfaSection.scss'; import {formatTime, formatDate} from 'js/utils'; +import pageState from 'js/pageState.store'; bem.SecurityRow = makeBem(null, 'security-row'); bem.SecurityRow__header = makeBem(bem.SecurityRow, 'header'); @@ -70,7 +70,7 @@ export default class SecurityRoute extends React.Component<{}, SecurityState> { ), mfaActions.activate.completed.listen(this.mfaActivating.bind(this)), mfaActions.confirmCode.completed.listen(this.mfaActivated.bind(this)), - mfaActions.deactivate.completed.listen(this.mfaDeactivated.bind(this)), + mfaActions.deactivate.completed.listen(this.mfaDeactivated.bind(this)) ); mfaActions.getUserMethods(); @@ -103,7 +103,7 @@ export default class SecurityRoute extends React.Component<{}, SecurityState> { mfaActivating(response: MfaActivatedResponse) { if (response && !response.inModal) { - stores.pageState.showModal({ + pageState.showModal({ type: MODAL_TYPES.MFA_MODALS, qrCode: response.details, modalType: 'qr', @@ -133,7 +133,7 @@ export default class SecurityRoute extends React.Component<{}, SecurityState> { if (isActive) { mfaActions.activate(); } else { - stores.pageState.showModal({ + pageState.showModal({ type: MODAL_TYPES.MFA_MODALS, modalType: 'deactivate', customModalHeader: this.renderCustomHeader(), @@ -149,7 +149,7 @@ export default class SecurityRoute extends React.Component<{}, SecurityState> { ) { evt.preventDefault(); - stores.pageState.showModal({ + pageState.showModal({ type: MODAL_TYPES.MFA_MODALS, modalType: type, customModalHeader: this.renderCustomHeader(), @@ -202,7 +202,7 @@ export default class SecurityRoute extends React.Component<{}, SecurityState> { /> - + {this.state.isMfaActive && this.state.isMfaAvailable && ( @@ -220,7 +220,7 @@ export default class SecurityRoute extends React.Component<{}, SecurityState> {
    diff --git a/jsapp/js/account/security/password/passwordSection.module.scss b/jsapp/js/account/security/password/passwordSection.module.scss index 1a0ebcd949..812f7f329b 100644 --- a/jsapp/js/account/security/password/passwordSection.module.scss +++ b/jsapp/js/account/security/password/passwordSection.module.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; @use 'scss/libs/_mdl'; diff --git a/jsapp/js/account/security/sso/ssoSection.component.tsx b/jsapp/js/account/security/sso/ssoSection.component.tsx index e2c8951b99..8dd7702195 100644 --- a/jsapp/js/account/security/sso/ssoSection.component.tsx +++ b/jsapp/js/account/security/sso/ssoSection.component.tsx @@ -1,10 +1,10 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import {observer} from 'mobx-react-lite'; import sessionStore from 'js/stores/session'; import styles from './ssoSection.module.scss'; import {deleteSocialAccount} from './sso.api'; import Button from 'jsapp/js/components/common/button'; -import envStore from 'jsapp/js/envStore'; +import envStore, {SocialApp} from 'jsapp/js/envStore'; import classNames from 'classnames'; const SsoSection = observer(() => { @@ -23,6 +23,16 @@ const SsoSection = observer(() => { } }; + const providerLink = useCallback((socialApp: SocialApp) => { + let providerPath = ''; + if(socialApp.provider === 'openid_connect') { + providerPath = 'oidc/' + socialApp.provider_id; + } else { + providerPath = socialApp.provider_id || socialApp.provider; + } + return `accounts/${providerPath}/login/?process=connect&next=%2F%23%2Faccount%2Fsecurity`; + }, [sessionStore.currentAccount]) + if (socialApps.length === 0 && socialAccounts.length === 0) { return <>; } @@ -50,11 +60,7 @@ const SsoSection = observer(() => {
    {socialApps.map((socialApp) => (
    + )} + + + + + + + + + + + + + {parseInt(projectData.count) === 0 ? ( + + + + + + ) : ( + + {projectData.results.map((project) => renderProjectRow(project))} + + )} +
    + + {t('Submissions (Total)')}{t('Submissions')}{t('Data storage')}{t('Transcript minutes')}{t('Translation characters')} + +
    +
    + {t('There are no projects to display.')} +
    +
    + +
    + ); +}; + +export default ProjectBreakdown; diff --git a/jsapp/js/account/usage/usageTopTabs.tsx b/jsapp/js/account/usage/usageTopTabs.tsx new file mode 100644 index 0000000000..657cc3a1bf --- /dev/null +++ b/jsapp/js/account/usage/usageTopTabs.tsx @@ -0,0 +1,39 @@ +import React, {useState} from 'react'; +import Tabs from 'jsapp/js/components/common/tabs'; +import ProjectBreakdown from './usageProjectBreakdown'; +import {useNavigate} from 'react-router-dom'; +import Usage from './usage.component'; +import {ACCOUNT_ROUTES} from 'js/account/routes.constants'; + +interface UsageTopTabsProps { + activeRoute: string; +} + +const usageTopTabs: React.FC = ({activeRoute}) => { + const [selectedTab, setSelectedTab] = useState(activeRoute); + const navigate = useNavigate(); + + const handleTabChange = (route: string) => { + setSelectedTab(route); + navigate(route); + }; + + return ( +
    + + {selectedTab === ACCOUNT_ROUTES.USAGE ? : } +
    + ); +}; + +export default usageTopTabs; diff --git a/jsapp/js/account/usage/useUsage.hook.ts b/jsapp/js/account/usage/useUsage.hook.ts index f07f6d44a5..e35361ecaf 100644 --- a/jsapp/js/account/usage/useUsage.hook.ts +++ b/jsapp/js/account/usage/useUsage.hook.ts @@ -1,8 +1,9 @@ -import {createContext, useEffect, useState} from 'react'; -import type {RecurringInterval} from 'js/account/stripe.types'; +import {createContext, useCallback} from 'react'; +import type {Organization, RecurringInterval} from 'js/account/stripe.types'; import {getSubscriptionInterval} from 'js/account/stripe.api'; -import {formatRelativeTime, truncateNumber} from 'js/utils'; -import {getUsageForOrganization} from 'js/account/usage/usage.api'; +import {convertSecondsToMinutes, formatRelativeTime} from 'js/utils'; +import {getUsage} from 'js/account/usage/usage.api'; +import {useApiFetcher, withApiFetcher} from 'js/hooks/useApiFetcher.hook'; export interface UsageState { storage: number; @@ -13,9 +14,7 @@ export interface UsageState { currentYearStart: string; billingPeriodEnd: string | null; trackingPeriod: RecurringInterval; - isPeriodLoaded: boolean; lastUpdated?: String | null; - isLoaded: boolean; } const INITIAL_USAGE_STATE: UsageState = Object.freeze({ @@ -27,70 +26,55 @@ const INITIAL_USAGE_STATE: UsageState = Object.freeze({ currentYearStart: '', billingPeriodEnd: null, trackingPeriod: 'month', - isPeriodLoaded: false, lastUpdated: '', - isLoaded: false, }); -export function useUsage() { - const [usage, setUsage] = useState(INITIAL_USAGE_STATE); - - // get subscription interval (monthly, yearly) from the subscriptionStore when ready - useEffect(() => { - getSubscriptionInterval().then((subscriptionInterval) => { - setUsage((prevState) => { - return { - ...prevState, - trackingPeriod: subscriptionInterval || 'month', - isPeriodLoaded: true, - }; - }); - }); - }, []); - - useEffect(() => { - if (!usage.isPeriodLoaded) { - return; +const loadUsage = async ( + organizationId: string | null +): Promise => { + if (!organizationId) { + throw Error(t('No organization found')); + } + const trackingPeriod = await getSubscriptionInterval(); + const usage = await getUsage(organizationId); + if (!usage) { + throw Error(t("Couldn't get usage data")); + } + let lastUpdated: UsageState['lastUpdated'] = null; + if ('headers' in usage && usage.headers instanceof Headers) { + const lastUpdateDate = usage.headers.get('date'); + if (lastUpdateDate) { + lastUpdated = formatRelativeTime(lastUpdateDate); } - getUsageForOrganization().then((data) => { - if (!data) { - return; - } - let lastUpdated: UsageState['lastUpdated'] = null; - if ('headers' in data && data.headers instanceof Headers) { - const lastUpdateDate = data.headers.get('date'); - if (lastUpdateDate) { - lastUpdated = formatRelativeTime(lastUpdateDate); - } - } - setUsage((prevState) => { - return { - ...prevState, - storage: data.total_storage_bytes, - submissions: - data.total_submission_count[`current_${usage.trackingPeriod}`], - transcriptionMinutes: Math.floor( - truncateNumber( - data.total_nlp_usage[ - `asr_seconds_current_${usage.trackingPeriod}` - ] / 60 - ) - ), // seconds to minutes - translationChars: - data.total_nlp_usage[ - `mt_characters_current_${usage.trackingPeriod}` - ], - currentMonthStart: data.current_month_start, - currentYearStart: data.current_year_start, - billingPeriodEnd: data.billing_period_end, - lastUpdated: lastUpdated, - isLoaded: true, - }; - }); - }); - }, [usage.isPeriodLoaded]); + } + return { + storage: usage.total_storage_bytes, + submissions: usage.total_submission_count[`current_${trackingPeriod}`], + transcriptionMinutes: convertSecondsToMinutes( + usage.total_nlp_usage[`asr_seconds_current_${trackingPeriod}`] + ), + translationChars: + usage.total_nlp_usage[`mt_characters_current_${trackingPeriod}`], + currentMonthStart: usage.current_month_start, + currentYearStart: usage.current_year_start, + billingPeriodEnd: usage.billing_period_end, + trackingPeriod, + lastUpdated, + }; +}; - return usage; -} +export const useUsage = (organizationId: string | null) => { + const fetcher = useApiFetcher( + () => { + return loadUsage(organizationId); + }, + INITIAL_USAGE_STATE, + { + reloadEverySeconds: 15 * 60, + skipInitialLoad: !organizationId, + } + ); -export const UsageContext = createContext(INITIAL_USAGE_STATE); + return fetcher; +}; +export const UsageContext = createContext(withApiFetcher(INITIAL_USAGE_STATE)); diff --git a/jsapp/js/account/usage/yourPlan.component.tsx b/jsapp/js/account/usage/yourPlan.component.tsx index b4f4a3cf4c..ed1651cba8 100644 --- a/jsapp/js/account/usage/yourPlan.component.tsx +++ b/jsapp/js/account/usage/yourPlan.component.tsx @@ -1,16 +1,28 @@ -import usageStyles from 'js/account/usage/usage.module.scss'; import styles from 'js/account/usage/yourPlan.module.scss'; import React, {useContext, useMemo, useState} from 'react'; import {formatDate} from 'js/utils'; -import Badge from 'js/components/common/badge'; +import Badge, {BadgeColor} from 'js/components/common/badge'; import subscriptionStore from 'js/account/subscriptionStore'; import sessionStore from 'js/stores/session'; import BillingButton from 'js/account/plans/billingButton.component'; -import {ACCOUNT_ROUTES} from 'js/account/routes'; import envStore from 'js/envStore'; -import {PlanNames} from 'js/account/stripe.types'; -import {UsageContext} from 'js/account/usage/useUsage.hook'; -import LimitNotifications from 'js/components/usageLimits/limitNotifications.component'; +import { + PlanNames, + Product, + SubscriptionChangeType, +} from 'js/account/stripe.types'; +import {ProductsContext} from '../useProducts.hook'; +import {getSubscriptionChangeDetails} from '../stripe.utils'; +import {ACCOUNT_ROUTES} from 'js/account/routes.constants'; + +const BADGE_COLOR_KEYS: {[key in SubscriptionChangeType]: BadgeColor} = { + [SubscriptionChangeType.RENEWAL]: 'light-blue', + [SubscriptionChangeType.CANCELLATION]: 'light-red', + [SubscriptionChangeType.PRODUCT_CHANGE]: 'light-amber', + [SubscriptionChangeType.PRICE_CHANGE]: 'light-amber', + [SubscriptionChangeType.QUANTITY_CHANGE]: 'light-amber', + [SubscriptionChangeType.NO_CHANGE]: 'light-blue', +}; /* * Show the user's current plan and any storage add-ons, with links to the Plans page @@ -19,7 +31,7 @@ export const YourPlan = () => { const [subscriptions] = useState(() => subscriptionStore); const [env] = useState(() => envStore); const [session] = useState(() => sessionStore); - const usage = useContext(UsageContext); + const [productsContext] = useContext(ProductsContext); /* * The plan name displayed to the user. This will display, in order of precedence: @@ -45,15 +57,21 @@ export const YourPlan = () => { return formatDate(date); }, [env.isReady, subscriptions.isInitialised]); + const currentPlan = useMemo(() => { + if (subscriptionStore.planResponse.length) { + return subscriptions.planResponse[0]; + } else { + return null; + } + }, [env.isReady, subscriptions.isInitialised]); + + const subscriptionUpdate = useMemo(() => { + return getSubscriptionChangeDetails(currentPlan, productsContext.products); + }, [currentPlan, productsContext.isLoaded]); + return (
    -
    -

    {t('Your plan')}

    -
    -
    - -

    @@ -66,19 +84,40 @@ export const YourPlan = () => { startDate )} - {usage.billingPeriodEnd && subscriptions.planResponse.length > 0 && ( + {subscriptionUpdate && ( - {t('Renews on ##renewal_date##').replace( - '##renewal_date##', - formatDate(usage.billingPeriodEnd) - )} + {subscriptionUpdate.type === SubscriptionChangeType.RENEWAL && + t('Renews on ##renewal_date##').replace( + '##renewal_date##', + formatDate(subscriptionUpdate.date) + )} + {[ + SubscriptionChangeType.CANCELLATION, + SubscriptionChangeType.PRODUCT_CHANGE, + ].includes(subscriptionUpdate.type) && + t('Ends on ##end_date##').replace( + '##end_date##', + formatDate(subscriptionUpdate.date) + )} + {subscriptionUpdate.type === + SubscriptionChangeType.QUANTITY_CHANGE && + t('Changing usage limits on ##change_date##').replace( + '##change_date##', + formatDate(subscriptionUpdate.date) + )} + {subscriptionUpdate.type === + SubscriptionChangeType.PRICE_CHANGE && + t('Switching to monthly on ##change_date##').replace( + '##change_date##', + formatDate(subscriptionUpdate.date) + )} } /> @@ -102,6 +141,61 @@ export const YourPlan = () => { */}

    + {subscriptionUpdate?.type === SubscriptionChangeType.CANCELLATION && ( +
    + {t( + 'Your ##current_plan## plan has been canceled but will remain active until the end of the billing period.' + ).replace('##current_plan##', planName)} +
    + )} + {subscriptionUpdate?.type === SubscriptionChangeType.PRODUCT_CHANGE && ( +
    + {t( + 'Your ##current_plan## plan will change to the ##next_plan## plan on' + ) + .replace('##current_plan##', planName) + .replace('##next_plan##', subscriptionUpdate.nextProduct!.name)} +   + + {t( + '. You can continue using ##current_plan## plan features until the end of the billing period.' + ).replace('##current_plan##', planName)} +
    + )} + {subscriptionUpdate?.type === SubscriptionChangeType.QUANTITY_CHANGE && ( +
    + {t( + 'Your ##current_plan## plan will change to include up to ##submission_quantity## submissions/month starting from' + ) + .replace('##current_plan##', planName) + .replace( + '##submission_quantity##', + ( + subscriptions.planResponse[0].schedule.phases?.[1].items[0] + .quantity || '' + ).toLocaleString() + )} +   + + . +
    + )} + {subscriptionUpdate?.type === SubscriptionChangeType.PRICE_CHANGE && ( +
    + {t( + 'Your ##current_plan## plan will change from annual to monthly starting from' + ).replace('##current_plan##', planName)} +   + + . +
    + )}
    ); }; diff --git a/jsapp/js/account/usage/yourPlan.module.scss b/jsapp/js/account/usage/yourPlan.module.scss index 25bc89bf6a..938b0ebe85 100644 --- a/jsapp/js/account/usage/yourPlan.module.scss +++ b/jsapp/js/account/usage/yourPlan.module.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; .section { @@ -8,13 +8,7 @@ align-items: center; justify-content: space-between; margin-block-end: 1.5em; - border-block: 1px solid colors.$kobo-gray-85 -} - -.banner { - flex-basis: 100%; - margin-top: 1em; - margin-bottom: -1em; + border-block: sizes.$x1 solid colors.$kobo-gray-96; } .plan { @@ -26,12 +20,19 @@ margin-block: 1.5em; } -.renewal { +.updateBadge { font-weight: bold; font-size: sizes.$r12; padding-inline: 0.25em; } +.subscriptionChangeNotice { + width: 100%; + padding: sizes.$x12 sizes.$x16; + background-color: colors.$kobo-gray-96; + margin-bottom: 1.5em; +} + .start { padding-inline-end: 1em; } diff --git a/jsapp/js/account/useProducts.hook.ts b/jsapp/js/account/useProducts.hook.ts new file mode 100644 index 0000000000..dc2ecd33df --- /dev/null +++ b/jsapp/js/account/useProducts.hook.ts @@ -0,0 +1,38 @@ +import {createContext} from 'react'; +import type {Product} from 'js/account/stripe.types'; +import {getProducts} from 'js/account/stripe.api'; +import {when} from 'mobx'; +import envStore from 'js/envStore'; +import {useApiFetcher, withApiFetcher} from 'js/hooks/useApiFetcher.hook'; + +export interface ProductsState { + products: Product[]; + isLoaded: boolean; +} + +const INITIAL_PRODUCTS_STATE: ProductsState = Object.freeze({ + products: [], + isLoaded: false, +}); + +const loadProducts = async () => { + await when(() => envStore.isReady); + if (!envStore.data.stripe_public_key) { + return { + products: [], + isLoaded: true, + }; + } + const products = await getProducts(); + return { + products: products.results, + isLoaded: true, + }; +}; + +export const useProducts = () => + useApiFetcher(loadProducts, INITIAL_PRODUCTS_STATE); + +export const ProductsContext = createContext( + withApiFetcher(INITIAL_PRODUCTS_STATE) +); diff --git a/jsapp/js/actions.d.ts b/jsapp/js/actions.d.ts index 4d4ba0ff85..5904d59a14 100644 --- a/jsapp/js/actions.d.ts +++ b/jsapp/js/actions.d.ts @@ -31,6 +31,17 @@ interface GetSubmissionCompletedDefinition extends Function { listen: (callback: (response: SubmissionResponse) => void) => Function; } +interface GetSubmissionsDefinition extends Function { + (options: GetSubmissionsOptions): void; + completed: GetSubmissionsCompletedDefinition; + failed: GenericFailedDefinition; +} + +interface GetSubmissionsCompletedDefinition extends Function { + (response: PaginatedResponse, options: GetSubmissionsOptions): void; + listen: (callback: (response: PaginatedResponse, options: GetSubmissionsOptions) => void) => Function; +} + interface GetProcessingSubmissionsDefinition extends Function { (assetUid: string, questionsPaths: string[]): void; completed: GetProcessingSubmissionsCompletedDefinition; @@ -89,6 +100,66 @@ interface GetExportCompletedDefinition extends Function { listen: (callback: (response: any) => void) => Function; } +interface TableUpdateSettingsDefinition extends Function { + (assetUid: string, newSettings: object): void; + completed: GenericCallbackDefinition; + failed: GenericFailedDefinition; +} + +interface UpdateSubmissionValidationStatusDefinition extends Function { + ( + assetUid: string, + submissionUid: string, + data: {'validation_status.uid': ValidationStatus} + ): void; + completed: AnySubmissionValidationStatusCompletedDefinition; + failed: GenericFailedDefinition; +} + +interface AnySubmissionValidationStatusCompletedDefinition extends Function { + (result: ValidationStatusResponse, sid: string): void; + listen: (callback: (result: ValidationStatusResponse, sid: string) => void) => Function; +} + +interface RemoveSubmissionValidationStatusDefinition extends Function { + (assetUid: string, submissionUid: string): void; + completed: AnySubmissionValidationStatusCompletedDefinition; + failed: GenericFailedDefinition; +} + +interface DuplicateSubmissionDefinition extends Function { + (assetUid: string, submissionUid: string, data: SubmissionResponse): void; + completed: DuplicateSubmissionCompletedDefinition; + failed: GenericFailedDefinition; +} + +interface DuplicateSubmissionCompletedDefinition extends Function { + (assetUid: string, submissionUid: string, duplicatedSubmission: SubmissionResponse): void; + listen: (callback: (assetUid: string, submissionUid: string, duplicatedSubmission: SubmissionResponse) => void) => Function; +} + +interface GetUserDefinition extends Function { + (username: string): void; + completed: GetUserCompletedDefinition; + failed: GenericFailedDefinition; +} + +interface GetUserCompletedDefinition extends Function { + (response: AccountResponse): void; + listen: (callback: (response: AccountResponse) => void) => Function; +} + +interface SetAssetPublicDefinition extends Function { + (asset: AssetResponse, shouldSetAnonPerms: boolean): void; + completed: SetAssetPublicCompletedDefinition; + failed: GenericFailedDefinition; +} + +interface SetAssetPublicCompletedDefinition extends Function { + (assetUid: string, shouldSetAnonPerms: boolean): void; + listen: (callback: (assetUid: string, shouldSetAnonPerms: boolean) => void) => Function; +} + // NOTE: as you use more actions in your ts files, please extend this namespace, // for now we are defining only the ones we need. export namespace actions { @@ -114,18 +185,20 @@ export namespace actions { listTags: GenericDefinition; createResource: GenericDefinition; updateAsset: UpdateAssetDefinition; - updateSubmissionValidationStatus: GenericDefinition; - removeSubmissionValidationStatus: GenericDefinition; + updateSubmissionValidationStatus: UpdateSubmissionValidationStatusDefinition; + removeSubmissionValidationStatus: RemoveSubmissionValidationStatusDefinition; deleteSubmission: GenericDefinition; - duplicateSubmission: GenericDefinition; + duplicateSubmission: DuplicateSubmissionDefinition; refreshTableSubmissions: GenericDefinition; getAssetFiles: GenericDefinition; }; const hooks: object; - const misc: object; + const misc: { + getUser: GetUserDefinition; + }; const reports: object; const table: { - updateSettings: (assetUid: string, newSettings: object) => void; + updateSettings: TableUpdateSettingsDefinition; }; const map: object; const permissions: { @@ -135,6 +208,7 @@ export namespace actions { assignAssetPermission: GenericDefinition; bulkSetAssetPermissions: GenericDefinition; getAssetPermissions: GenericDefinition; + setAssetPublic: SetAssetPublicDefinition; }; const help: { getInAppMessages: GenericDefinition; @@ -145,7 +219,7 @@ export namespace actions { const submissions: { getSubmission: GetSubmissionDefinition; getSubmissionByUuid: GetSubmissionDefinition; - getSubmissions: GenericDefinition; + getSubmissions: GetSubmissionsDefinition; getProcessingSubmissions: GetProcessingSubmissionsDefinition; bulkDeleteStatus: GenericDefinition; bulkPatchStatus: GenericDefinition; diff --git a/jsapp/js/actions.es6 b/jsapp/js/actions.es6 index 6c8ffd8edc..964b7c8089 100644 --- a/jsapp/js/actions.es6 +++ b/jsapp/js/actions.es6 @@ -103,9 +103,6 @@ permissionsActions.assignAssetPermission.completed.listen((uid) => { permissionsActions.copyPermissionsFrom.completed.listen((sourceUid, targetUid) => { actions.resources.loadAsset({id: targetUid}); }); -permissionsActions.setAssetPublic.completed.listen((uid) => { - actions.resources.loadAsset({id: uid}); -}); permissionsActions.removeAssetPermission.completed.listen((uid, isNonOwner) => { // Avoid this call if a non-owner removed their own permissions as it will fail if (!isNonOwner) { @@ -439,8 +436,14 @@ actions.resources.loadAsset.listen(function (params, refresh = false) { }) .fail(actions.resources.loadAsset.failed); } else if (assetCache[params.id] !== 'pending') { - // we have a cache entry, use that - actions.resources.loadAsset.completed(assetCache[params.id]); + // HACK: because some old pieces of code relied on the fact that loadAsset + // was always async, we add this timeout to mimick that functionality. + // Without it we were encountering bugs, as things were happening much + // earlier than anticipated. + setTimeout(() => { + // we have a cache entry, use that + actions.resources.loadAsset.completed(assetCache[params.id]); + }, 0); } // the cache entry for this asset is currently loading, do nothing }); diff --git a/jsapp/js/actions/submissions.es6 b/jsapp/js/actions/submissions.ts similarity index 75% rename from jsapp/js/actions/submissions.es6 rename to jsapp/js/actions/submissions.ts index b8f5945b4d..93c3a14b54 100644 --- a/jsapp/js/actions/submissions.es6 +++ b/jsapp/js/actions/submissions.ts @@ -4,8 +4,15 @@ import Reflux from 'reflux'; import {dataInterface} from 'js/dataInterface'; -import {notify} from 'utils'; +import {notify} from 'js/utils'; import {ROOT_URL} from 'js/constants'; +import type { + GetSubmissionsOptions, + PaginatedResponse, + SubmissionResponse, + FailResponse, + BulkSubmissionsRequest, +} from 'js/dataInterface'; const submissionsActions = Reflux.createActions({ getSubmission: {children: ['completed', 'failed']}, @@ -18,25 +25,11 @@ const submissionsActions = Reflux.createActions({ getProcessingSubmissions: {children: ['completed', 'failed']}, }); -/** - * @typedef SortObj - * @param {string} id - column name - * @param {boolean} desc - `true` for descending and `false` for ascending - */ - /** * NOTE: all of the parameters have their default values defined for * `dataInterface` function. - * - * @param {object} options - * @param {string} options.uid - the asset uid - * @param {number} [options.pageSize] - * @param {number} [options.page] - * @param {SortObj[]} [options.sort] - * @param {string[]} [options.fields] - * @param {string} [options.filter] */ -submissionsActions.getSubmissions.listen((options) => { +submissionsActions.getSubmissions.listen((options: GetSubmissionsOptions) => { dataInterface.getSubmissions( options.uid, options.pageSize, @@ -45,21 +38,20 @@ submissionsActions.getSubmissions.listen((options) => { options.fields, options.filter ) - .done((response) => { + .done((response: PaginatedResponse) => { submissionsActions.getSubmissions.completed(response, options); }) - .fail((response) => { + .fail((response: FailResponse) => { submissionsActions.getSubmissions.failed(response, options); }); }); /** * This gets an array of submission uuids - * @param {string} assetUid */ -submissionsActions.getProcessingSubmissions.listen((assetUid, questionsPaths) => { +submissionsActions.getProcessingSubmissions.listen((assetUid: string, questionsPaths: string[]) => { let fields = ''; - questionsPaths.forEach((questionPath) => { + questionsPaths.forEach((questionPath: string) => { fields += `,"${questionPath}"`; }); @@ -75,7 +67,7 @@ submissionsActions.getProcessingSubmissions.failed.listen(() => { notify(t('Failed to get submissions uuids.'), 'error'); }); -submissionsActions.getSubmission.listen((assetUid, submissionId) => { +submissionsActions.getSubmission.listen((assetUid: string, submissionId: string) => { dataInterface.getSubmission(assetUid, submissionId) .done(submissionsActions.getSubmission.completed) .fail(submissionsActions.getSubmission.failed); @@ -83,25 +75,24 @@ submissionsActions.getSubmission.listen((assetUid, submissionId) => { // There is no shortcut endpoint to get submission using uuid, so we have to // make a queried call over all submissions. -submissionsActions.getSubmissionByUuid.listen((assetUid, submissionUuid) => { +submissionsActions.getSubmissionByUuid.listen((assetUid: string, submissionUuid: string) => { // `_uuid` is the legacy identifier that changes (per OpenRosa spec) after every edit; // `meta/rootUuid` remains consistent across edits. - let query = { + const query = JSON.stringify({ '$or': [ {'meta/rootUuid': submissionUuid}, {'_uuid': submissionUuid}, ], - }; - query = JSON.stringify(query); + }); $.ajax({ dataType: 'json', method: 'GET', url: `${ROOT_URL}/api/v2/assets/${assetUid}/data/?query=${query}`, }) - .done((response) => { + .done((response: PaginatedResponse) => { // preferentially return a result matching the persistent UUID submissionsActions.getSubmissionByUuid.completed( - response.results.find((e) => e['meta/rootUuid'] === submissionUuid) || + response.results.find((sub) => sub['meta/rootUuid'] === submissionUuid) || response.results[0] ); }) @@ -111,7 +102,7 @@ submissionsActions.getSubmissionByUuid.failed.listen(() => { notify(t('Failed to get submission.'), 'error'); }); -submissionsActions.bulkDeleteStatus.listen((uid, data) => { +submissionsActions.bulkDeleteStatus.listen((uid: string, data: BulkSubmissionsRequest) => { dataInterface.bulkRemoveSubmissionsValidationStatus(uid, data) .done(submissionsActions.bulkDeleteStatus.completed) .fail(submissionsActions.bulkDeleteStatus.failed); @@ -120,7 +111,7 @@ submissionsActions.bulkDeleteStatus.failed.listen(() => { notify(t('Failed to update submissions.'), 'error'); }); -submissionsActions.bulkPatchStatus.listen((uid, data) => { +submissionsActions.bulkPatchStatus.listen((uid: string, data: BulkSubmissionsRequest) => { dataInterface.bulkPatchSubmissionsValidationStatus(uid, data) .done(submissionsActions.bulkPatchStatus.completed) .fail(submissionsActions.bulkPatchStatus.failed); @@ -135,12 +126,12 @@ submissionsActions.bulkPatchStatus.failed.listen(() => { * @param {object} data * @param {string} data. - with a new value, repeat with different fields if necessary */ -submissionsActions.bulkPatchValues.listen((uid, submissionIds, data) => { +submissionsActions.bulkPatchValues.listen((uid: string, submissionIds: string[], data: BulkSubmissionsRequest) => { dataInterface.bulkPatchSubmissionsValues(uid, submissionIds, data) .done(submissionsActions.bulkPatchValues.completed) .fail(submissionsActions.bulkPatchValues.failed); }); -submissionsActions.bulkPatchValues.completed.listen((response) => { +submissionsActions.bulkPatchValues.completed.listen((response: {failures: number}) => { if (response.failures !== 0) { notify(t('Failed to update some submissions values.'), 'error'); } @@ -149,7 +140,7 @@ submissionsActions.bulkPatchValues.failed.listen(() => { notify(t('Failed to update submissions values.'), 'error'); }); -submissionsActions.bulkDelete.listen((uid, data) => { +submissionsActions.bulkDelete.listen((uid: string, data: BulkSubmissionsRequest) => { dataInterface.bulkDeleteSubmissions(uid, data) .done(submissionsActions.bulkDelete.completed) .fail(submissionsActions.bulkDelete.failed); diff --git a/jsapp/js/alertify.ts b/jsapp/js/alertify.ts index 7c2d4896c8..499dd0c6c9 100644 --- a/jsapp/js/alertify.ts +++ b/jsapp/js/alertify.ts @@ -9,7 +9,7 @@ import {render} from 'react-dom'; interface MultiConfirmButton { label: string; /** Defaults to gray. */ - color?: 'blue' | 'red'; + color?: 'blue' | 'dark-red'; icon?: IconName; isDisabled?: boolean; callback: (() => void) | undefined; @@ -64,7 +64,7 @@ export function multiConfirm( let buttonClass = alertify.defaults.theme.input; if (button.color === 'blue') { buttonClass = alertify.defaults.theme.ok; - } else if (button.color === 'red') { + } else if (button.color === 'dark-red') { buttonClass = alertify.defaults.theme.cancel; } diff --git a/jsapp/js/api.ts b/jsapp/js/api.ts index 5baca6a178..581be888d4 100644 --- a/jsapp/js/api.ts +++ b/jsapp/js/api.ts @@ -53,18 +53,21 @@ export function handleApiFail(response: FailResponse, toastMessage?: string) { if (toastMessage || !displayMessage) { // display toastMessage or, if we don't have *any* message available, use a generic error displayMessage = toastMessage || t('An error occurred'); - if (response.status || response.statusText) { - // if we have a status, add it to the displayed message - displayMessage += `\n\n${response.status} ${response.statusText}`; - } else if (!window.navigator.onLine) { + + if (!window.navigator.onLine) { // another general case — the original fetch response.message might have // something more useful to say. displayMessage += '\n\n' + t('Your connection is offline'); } } + let errorMessageDisplay = message; + if (response.status || response.statusText) { + errorMessageDisplay = `${response.status} ${response.statusText}`; + } + // show the error message to the user - notify.error(displayMessage); + notify.error(displayMessage, undefined, errorMessageDisplay); // send the message to our error tracker Sentry.captureMessage(message || displayMessage); @@ -138,7 +141,9 @@ const fetchData = async ( // For when it's needed we pass authentication data if (method === 'DELETE' || data) { - const csrfCookie = document.cookie.match(/csrftoken=(\w{64})/); + // Need to support old token (64 characters - prior to Django 4.1) + // and new token (32 characters). + const csrfCookie = document.cookie.match(/csrftoken=(\w{32,64})/); if (csrfCookie) { headers['X-CSRFToken'] = csrfCookie[1]; } diff --git a/jsapp/js/app.js b/jsapp/js/app.jsx similarity index 55% rename from jsapp/js/app.js rename to jsapp/js/app.jsx index a9d68ebb35..a0893685ad 100644 --- a/jsapp/js/app.js +++ b/jsapp/js/app.jsx @@ -3,12 +3,10 @@ */ import React from 'react'; -import PropTypes from 'prop-types'; import DocumentTitle from 'react-document-title'; import {Outlet} from 'react-router-dom'; import reactMixin from 'react-mixin'; import Reflux from 'reflux'; -import {stores} from 'js/stores'; import 'js/surveyCompanionStore'; // importing it so it exists import {} from 'js/bemComponents'; // importing it so it exists import bem from 'js/bem'; @@ -22,17 +20,20 @@ import ToasterConfig from './toasterConfig'; import {withRouter, routerGetAssetId, router} from './router/legacy'; import {Tracking} from './router/useTracking'; import InvalidatedPassword from 'js/router/invalidatedPassword.component'; +import {RootContextProvider} from 'js/rootContextProvider.component'; import TOSAgreement from 'js/router/tosAgreement.component'; import { isInvalidatedPasswordRouteBlockerActive, isTOSAgreementRouteBlockerActive, } from 'js/router/routerUtils'; +import {isAnyProcessingRouteActive} from 'js/components/processing/routes.utils'; +import pageState from 'js/pageState.store'; class App extends React.Component { constructor(props) { super(props); this.state = Object.assign({ - pageState: stores.pageState.state, + pageState: pageState.state, }); } @@ -43,15 +44,25 @@ class App extends React.Component { onRouteChange() { // slide out drawer overlay on every page change (better mobile experience) if (this.state.pageState.showFixedDrawer) { - stores.pageState.setState({showFixedDrawer: false}); + pageState.setState({showFixedDrawer: false}); } // hide modal on every page change if (this.state.pageState.modal) { - stores.pageState.hideModal(); + pageState.hideModal(); } } + /** Whether to display the top header navigation and the side menu. */ + shouldDisplayMainLayoutElements() { + return ( + // Hide in Form Builder + !this.isFormBuilder() && + // Hide in Single Processing View + !isAnyProcessingRouteActive() + ); + } + render() { if (isInvalidatedPasswordRouteBlockerActive()) { return ; @@ -83,49 +94,59 @@ class App extends React.Component { ] = true; } + // TODO: We have multiple routes that shouldn't display `MainHeader`, + // `Drawer`, `ProjectTopTabs` etc. Instead of relying on CSS via + // `pageWrapperModifiers`, or `show` properties, or JSX logic - we should + // opt for a more sane, and singluar(!) solution. return ( - - - - @@ -226,6 +229,7 @@ class DataAttachmentColumnsForm extends React.Component { items={this.state.columnsToDisplay} onChange={this.onColumnSelected} disabled={this.state.isLoading} + className='data-attachment-columns-multicheckbox' /> {this.state.isLoading && @@ -233,14 +237,16 @@ class DataAttachmentColumnsForm extends React.Component { }
    - - {t('Accept')} - +
    diff --git a/jsapp/js/components/drawer.es6 b/jsapp/js/components/drawer.es6 index 8b244b270d..fbefdb6b83 100644 --- a/jsapp/js/components/drawer.es6 +++ b/jsapp/js/components/drawer.es6 @@ -1,11 +1,9 @@ import React, {lazy, Suspense} from 'react'; -import PropTypes from 'prop-types'; import reactMixin from 'react-mixin'; import autoBind from 'react-autobind'; import {observer} from 'mobx-react'; import Reflux from 'reflux'; import {NavLink} from 'react-router-dom'; -import {stores} from '../stores'; import sessionStore from '../stores/session'; import bem from 'js/bem'; import {searches} from '../searches'; @@ -17,6 +15,8 @@ import {ROUTES, PROJECTS_ROUTES} from 'js/router/routerConstants'; import SidebarFormsList from '../lists/sidebarForms'; import envStore from 'js/envStore'; import {router, routerIsActive, withRouter} from '../router/legacy'; +import Button from 'js/components/common/button'; +import pageState from 'js/pageState.store'; const AccountSidebar = lazy(() => import('js/account/accountSidebar')); @@ -39,11 +39,12 @@ const FormSidebar = observer( currentAssetId: false, files: [], }, - stores.pageState.state + pageState.state ); this.state = Object.assign(INITIAL_STATE, this.state); - this.stores = [stores.pageState]; + this.unlisteners = []; + this.stores = [pageState]; autoBind(this); } componentDidMount() { @@ -51,26 +52,35 @@ const FormSidebar = observer( // in dev environment. Unfortunately `router.subscribe` doesn't return // a cancel function, so we can't make it stop. // TODO: when refactoring this file, make sure not to use the legacy code. - router.subscribe(this.onRouteChange.bind(this)); + this.unlisteners.push( + router.subscribe(this.onRouteChange.bind(this)) + ); + } + componentWillUnmount() { + this.unlisteners.forEach((clb) => {clb();}); } newFormModal(evt) { evt.preventDefault(); - stores.pageState.showModal({ + pageState.showModal({ type: MODAL_TYPES.NEW_FORM, }); } render() { return ( - - - {t('new')} - + <> + + + )} @@ -144,11 +158,12 @@ const MainHeader = class MainHeader extends React.Component { - {asset.has_deployment && asset.deployment__submission_count !== null && ( - - {asset.deployment__submission_count} {t('submissions')} - - )} + {asset.has_deployment && + asset.deployment__submission_count !== null && ( + + {asset.deployment__submission_count} {t('submissions')} + + )} )} diff --git a/jsapp/js/components/header/mainHeader.module.scss b/jsapp/js/components/header/mainHeader.module.scss new file mode 100644 index 0000000000..c217263edb --- /dev/null +++ b/jsapp/js/components/header/mainHeader.module.scss @@ -0,0 +1,19 @@ +@use 'scss/colors'; +@use 'scss/breakpoints'; +@use 'scss/mixins'; + +.mobileMenuToggle { + @include mixins.buttonReset; + display: flex; + padding: 6px; + color: colors.$kobo-white; + margin-right: 8px; + + &:hover { + opacity: 0.8; + } + + @media screen and (min-width: breakpoints.$bMobileMenu) { + display: none; + } +} diff --git a/jsapp/js/components/languages/languageSelector.scss b/jsapp/js/components/languages/languageSelector.scss index f174d9702f..408c6ef987 100644 --- a/jsapp/js/components/languages/languageSelector.scss +++ b/jsapp/js/components/languages/languageSelector.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use "scss/sizes"; @use "scss/mixins"; @@ -185,7 +185,7 @@ .language-selector__not-found-message { padding: sizes.$x6 sizes.$x8; - text-align: left; + text-align: initial; font-style: italic; } diff --git a/jsapp/js/components/languages/languageSelector.tsx b/jsapp/js/components/languages/languageSelector.tsx index 26840f2bc6..916534e88a 100644 --- a/jsapp/js/components/languages/languageSelector.tsx +++ b/jsapp/js/components/languages/languageSelector.tsx @@ -277,7 +277,7 @@ class LanguageSelector extends React.Component<
  • @@ -97,5 +107,3 @@ class AssetContentSummary extends React.Component { ); } } - -export default AssetContentSummary; diff --git a/jsapp/js/components/library/assetInfoBox.scss b/jsapp/js/components/library/assetInfoBox.scss index cb38ee21f1..2e3ecbf6c3 100644 --- a/jsapp/js/components/library/assetInfoBox.scss +++ b/jsapp/js/components/library/assetInfoBox.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; .asset-info-box { display: flex; @@ -28,29 +28,6 @@ } } } - - .asset-info-box__toggle { - cursor: pointer; - color: colors.$kobo-blue; - background: transparent; - border: 0; - width: 100%; - text-align: center; - - &:hover { - color: colors.$kobo-dark-blue; - } - - &:active { - transform: translateY(1px); - } - - .k-icon { - font-size: 18px; - margin-right: 3px; - vertical-align: -5px; - } - } } .asset-info-box__cell { diff --git a/jsapp/js/components/library/assetInfoBox.es6 b/jsapp/js/components/library/assetInfoBox.tsx similarity index 74% rename from jsapp/js/components/library/assetInfoBox.es6 rename to jsapp/js/components/library/assetInfoBox.tsx index 2069729cf6..74c4001238 100644 --- a/jsapp/js/components/library/assetInfoBox.es6 +++ b/jsapp/js/components/library/assetInfoBox.tsx @@ -1,7 +1,4 @@ import React from 'react'; -import reactMixin from 'react-mixin'; -import autoBind from 'react-autobind'; -import Reflux from 'reflux'; import bem from 'js/bem'; import {actions} from 'js/actions'; import sessionStore from 'js/stores/session'; @@ -10,37 +7,58 @@ import {ASSET_TYPES} from 'js/constants'; import { notify, formatTime, -} from 'utils'; +} from 'js/utils'; import './assetInfoBox.scss'; +import type {AssetResponse, AccountResponse} from 'js/dataInterface'; +import Button from 'js/components/common/button'; + +interface AssetInfoBoxProps { + asset: AssetResponse; +} + +interface AssetInfoBoxState { + areDetailsVisible: boolean; + ownerData: AccountResponse | {username: string; date_joined: string} | null; +} /** - * @prop asset + * Displays some meta information about given asset. */ -class AssetInfoBox extends React.Component { - constructor(props){ +export default class AssetInfoBox extends React.Component< + AssetInfoBoxProps, + AssetInfoBoxState +> { + private unlisteners: Function[] = []; + + constructor(props: AssetInfoBoxProps){ super(props); this.state = { areDetailsVisible: false, ownerData: null, }; - autoBind(this); } componentDidMount() { if (!assetUtils.isSelfOwned(this.props.asset)) { - this.listenTo(actions.misc.getUser.completed, this.onGetUserCompleted); - this.listenTo(actions.misc.getUser.failed, this.onGetUserFailed); + this.unlisteners.push( + actions.misc.getUser.completed.listen(this.onGetUserCompleted.bind(this)), + actions.misc.getUser.failed.listen(this.onGetUserFailed.bind(this)) + ) actions.misc.getUser(this.props.asset.owner); } else { this.setState({ownerData: sessionStore.currentAccount}); } } + componentWillUnmount() { + this.unlisteners.forEach((clb) => {clb();}); + } + toggleDetails() { this.setState({areDetailsVisible: !this.state.areDetailsVisible}); } - onGetUserCompleted(userData) { + onGetUserCompleted(userData: AccountResponse) { this.setState({ownerData: userData}); } @@ -78,7 +96,9 @@ class AssetInfoBox extends React.Component { {this.state.areDetailsVisible && - {this.props.asset.settings.description || '-'} +
    + {this.props.asset.settings.description || '-'} +
    } @@ -136,16 +156,16 @@ class AssetInfoBox extends React.Component { - - {this.state.areDetailsVisible ? : } - {this.state.areDetailsVisible ? t('Hide full details') : t('Show full details')} - + - - + + + + {/* + TODO: the tooltips of these two buttons appear underneath them + causing an unnecessary space under the last table row to happen. + Let's try to fix this one day by introducing better tooltips. + */} + + + + } + disableShortening + /> + } + + {Object.keys(this.props.selectedRows).length > 0 && + + } + > + {(userCan(PERMISSIONS_CODENAMES.validate_submissions, this.props.asset) || userCanPartially(PERMISSIONS_CODENAMES.validate_submissions, this.props.asset)) && + VALIDATION_STATUS_OPTIONS.map((item, n) => { + return ( + + {t('Set status: ##status##').replace('##status##', item.label)} + + ); + }) + } + + } + + {Object.keys(this.props.selectedRows).length > 0 && this.props.asset.deployment__active && (userCan(PERMISSIONS_CODENAMES.change_submissions, this.props.asset) || userCanPartially(PERMISSIONS_CODENAMES.change_submissions, this.props.asset)) && + + ); + } + + return ( + + {renderSortButton(SortValues.ASCENDING)} + {renderSortButton(SortValues.DESCENDING)} + + {userCan(PERMISSIONS_CODENAMES.change_asset, props.asset) && ( + + )} + {userCan(PERMISSIONS_CODENAMES.change_asset, props.asset) && ( + + )} + + } + /> + ); +} diff --git a/jsapp/js/components/submissions/tableConstants.ts b/jsapp/js/components/submissions/tableConstants.ts index 972ec8a547..acf8853539 100644 --- a/jsapp/js/components/submissions/tableConstants.ts +++ b/jsapp/js/components/submissions/tableConstants.ts @@ -1,9 +1,11 @@ import { createEnum, - QUESTION_TYPES, META_QUESTION_TYPES, ADDITIONAL_SUBMISSION_PROPS, + QuestionTypeName, + MiscRowTypeName, } from 'js/constants'; +import type {AnyRowTypeName} from 'js/constants'; export const SUBMISSION_ACTIONS_ID = '__SubmissionActions'; @@ -27,10 +29,10 @@ export const EXCLUDED_COLUMNS = [ '_validation_status', ]; -export const SORT_VALUES = createEnum([ - 'ASCENDING', - 'DESCENDING', -]); +export enum SortValues { + ASCENDING = 'ASCENDING', + DESCENDING = 'DESCENDING', +} // This is the setting object name from `asset.settings` export const DATA_TABLE_SETTING = 'data-table'; @@ -45,10 +47,9 @@ export const DATA_TABLE_SETTINGS = Object.freeze({ }); export const TABLE_MEDIA_TYPES = createEnum([ - QUESTION_TYPES.image.id, - QUESTION_TYPES.audio.id, - QUESTION_TYPES.video.id, - QUESTION_TYPES.text.id, + QuestionTypeName.image, + QuestionTypeName.audio, + QuestionTypeName.video, META_QUESTION_TYPES['background-audio'], ]); @@ -59,21 +60,42 @@ CELLS_WIDTH_OVERRIDES[VALIDATION_STATUS_ID_PROP] = 125; CELLS_WIDTH_OVERRIDES[META_QUESTION_TYPES.start] = 110; CELLS_WIDTH_OVERRIDES[META_QUESTION_TYPES.end] = 110; CELLS_WIDTH_OVERRIDES[ADDITIONAL_SUBMISSION_PROPS._id] = 100; -CELLS_WIDTH_OVERRIDES[QUESTION_TYPES.image.id] = 110; -CELLS_WIDTH_OVERRIDES[QUESTION_TYPES.audio.id] = 170; -CELLS_WIDTH_OVERRIDES[QUESTION_TYPES.video.id] = 110; +CELLS_WIDTH_OVERRIDES[QuestionTypeName.image] = 110; +CELLS_WIDTH_OVERRIDES[QuestionTypeName.audio] = 170; +CELLS_WIDTH_OVERRIDES[QuestionTypeName.video] = 110; CELLS_WIDTH_OVERRIDES[META_QUESTION_TYPES['background-audio']] = 170; Object.freeze(CELLS_WIDTH_OVERRIDES); -export const TEXT_FILTER_QUESTION_TYPES = [ - QUESTION_TYPES.text.id, - QUESTION_TYPES.integer.id, - QUESTION_TYPES.decimal.id, - QUESTION_TYPES.date.id, - QUESTION_TYPES.time.id, - QUESTION_TYPES.datetime.id, - QUESTION_TYPES.barcode.id, - QUESTION_TYPES.calculate.id, +/** + * For these question types the UI will display a dropdown filter in Data Table + * for the matching column. + */ +export const DROPDOWN_FILTER_QUESTION_TYPES: AnyRowTypeName[] = [ + QuestionTypeName.select_multiple, + QuestionTypeName.select_one, +]; + +/** + * For these question types the UI will display a text filter in Data Table for + * the matching column. + */ +export const TEXT_FILTER_QUESTION_TYPES: AnyRowTypeName[] = [ + QuestionTypeName.barcode, + QuestionTypeName.calculate, + QuestionTypeName.date, + QuestionTypeName.datetime, + QuestionTypeName.decimal, + QuestionTypeName.integer, + QuestionTypeName.range, + QuestionTypeName.rank, + QuestionTypeName.score, + // TODO: for now there is no code in `table.es6` that makes the choices from + // file available there, so we fallback to text filter + QuestionTypeName.select_multiple_from_file, + QuestionTypeName.select_one_from_file, + // ENDTODO + QuestionTypeName.text, + QuestionTypeName.time, META_QUESTION_TYPES.start, META_QUESTION_TYPES.end, META_QUESTION_TYPES.username, @@ -81,8 +103,15 @@ export const TEXT_FILTER_QUESTION_TYPES = [ META_QUESTION_TYPES.phonenumber, META_QUESTION_TYPES.today, META_QUESTION_TYPES['background-audio'], + MiscRowTypeName.score__row, + MiscRowTypeName.rank__level, ]; +/** + * For these question ids the UI will display a text filter in Data Table for + * the matching column. We need this, because these are additional submission + * properties, so they don't have a question type attached to them. + */ export const TEXT_FILTER_QUESTION_IDS = [ '__version__', ADDITIONAL_SUBMISSION_PROPS._id, @@ -90,3 +119,21 @@ export const TEXT_FILTER_QUESTION_IDS = [ ADDITIONAL_SUBMISSION_PROPS._submission_time, ADDITIONAL_SUBMISSION_PROPS._submitted_by, ]; + +/** + * These are question types that will be filtered by the exact filter value + * (i.e. filter value is exactly the response). Any question type not on this + * list will be filtered by responses that include the value (i.e. filter value + * is part of the response). + * + * Every type that is not listed here is using "inexact" or "partial" match. + */ +export const FILTER_EXACT_TYPES: AnyRowTypeName[] = [ + QuestionTypeName.decimal, + QuestionTypeName.integer, + QuestionTypeName.range, + QuestionTypeName.rank, + QuestionTypeName.score, + QuestionTypeName.select_one, + QuestionTypeName.select_one_from_file, +]; diff --git a/jsapp/js/components/submissions/tableMediaPreview.es6 b/jsapp/js/components/submissions/tableMediaPreview.es6 index 17632165f3..80dba43b66 100644 --- a/jsapp/js/components/submissions/tableMediaPreview.es6 +++ b/jsapp/js/components/submissions/tableMediaPreview.es6 @@ -30,7 +30,7 @@ bem.TableMediaPreview__text = makeBem(bem.TableMediaPreview, 'text', 'div'); * Table cell replacement for media submissions * * @prop {string} questionType - * @prop {mediaAttachment} mediaAttachment - `null` for text questions + * @prop {string | mediaAttachment} mediaAttachment * @prop {string} mediaName - Backend stored media attachment file name or the content of a text question */ @@ -50,7 +50,7 @@ class TableMediaPreview extends React.Component { case QUESTION_TYPES.image.id: return ( - + ); case QUESTION_TYPES.audio.id: @@ -60,7 +60,7 @@ class TableMediaPreview extends React.Component { @@ -68,7 +68,7 @@ class TableMediaPreview extends React.Component { case QUESTION_TYPES.video.id: return ( @@ -94,7 +94,8 @@ class TableMediaPreview extends React.Component { render() { return ( - {this.props.questionType && this.renderPreviewByType()} + {typeof this.props.mediaAttachment === 'string' && this.props.mediaAttachment} + {typeof this.props.mediaAttachment === 'object' && this.props.questionType && this.renderPreviewByType()} ); } diff --git a/jsapp/js/components/submissions/tableMediaPreview.scss b/jsapp/js/components/submissions/tableMediaPreview.scss index 43dde30a96..4998cf61e5 100644 --- a/jsapp/js/components/submissions/tableMediaPreview.scss +++ b/jsapp/js/components/submissions/tableMediaPreview.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use "scss/sizes"; .table-media-preview { diff --git a/jsapp/js/components/submissions/tableSettings.es6 b/jsapp/js/components/submissions/tableSettings.es6 index bdf6aad8d2..9182494509 100644 --- a/jsapp/js/components/submissions/tableSettings.es6 +++ b/jsapp/js/components/submissions/tableSettings.es6 @@ -8,6 +8,7 @@ import {notify} from 'utils'; import {DATA_TABLE_SETTINGS} from 'js/components/submissions/tableConstants'; import {userCan} from 'js/components/permissions/utils'; import tableStore from 'js/components/submissions/tableStore'; +import Button from 'js/components/common/button'; import './tableSettings.scss'; /** @@ -117,14 +118,22 @@ class TableSettings extends React.Component { {userCan('change_asset', this.props.asset) && - - {t('Reset')} - +
  • - - {this.state.pageState.modal && ( - - )} - - {!this.isFormBuilder() && ( - - - - - )} - - + + + + + {this.shouldDisplayMainLayoutElements() && +
    + } + + - {!this.isFormBuilder() && ( - - {this.isFormSingle() && } - - + {this.state.pageState.modal && ( + )} - - - - + + {this.shouldDisplayMainLayoutElements() && ( + <> + + + + )} + + + {this.shouldDisplayMainLayoutElements() && ( + <> + {this.isFormSingle() && } + + + )} + + + + + + ); } } -App.contextTypes = {router: PropTypes.object}; -reactMixin(App.prototype, Reflux.connect(stores.pageState, 'pageState')); +reactMixin(App.prototype, Reflux.connect(pageState, 'pageState')); reactMixin(App.prototype, mixins.contextRouter); export default withRouter(App); diff --git a/jsapp/js/assetQuickActions.tsx b/jsapp/js/assetQuickActions.tsx index d954650a0e..eda41055ce 100644 --- a/jsapp/js/assetQuickActions.tsx +++ b/jsapp/js/assetQuickActions.tsx @@ -30,6 +30,7 @@ import permConfig from './components/permissions/permConfig'; import toast from 'react-hot-toast'; import {userCan} from './components/permissions/utils'; import {renderJSXMessage} from './alertify'; +import pageState from 'js/pageState.store'; export function openInFormBuilder(uid: string) { if (routerIsActive(ROUTES.LIBRARY)) { @@ -533,12 +534,12 @@ export function deployAsset( /** Opens a modal for sharing asset. */ export function manageAssetSharing(uid: string) { - stores.pageState.showModal({type: MODAL_TYPES.SHARING, uid: uid}); + pageState.showModal({type: MODAL_TYPES.SHARING, uid: uid}); } /** Opens a modal for replacing an asset using a file. */ export function replaceAssetForm(asset: AssetResponse | ProjectViewAsset) { - stores.pageState.showModal({type: MODAL_TYPES.REPLACE_PROJECT, asset: asset}); + pageState.showModal({type: MODAL_TYPES.REPLACE_PROJECT, asset: asset}); } /** @@ -547,7 +548,7 @@ export function replaceAssetForm(asset: AssetResponse | ProjectViewAsset) { * up front via `asset` parameter. */ export function manageAssetLanguages(uid: string, asset?: AssetResponse) { - stores.pageState.showModal({ + pageState.showModal({ type: MODAL_TYPES.FORM_LANGUAGES, assetUid: uid, asset: asset, @@ -555,12 +556,12 @@ export function manageAssetLanguages(uid: string, asset?: AssetResponse) { } export function manageAssetEncryption(uid: string) { - stores.pageState.showModal({type: MODAL_TYPES.ENCRYPT_FORM, assetUid: uid}); + pageState.showModal({type: MODAL_TYPES.ENCRYPT_FORM, assetUid: uid}); } /** Opens a modal for modifying asset tags (also editable in Details Modal). */ export function modifyAssetTags(asset: AssetResponse | ProjectViewAsset) { - stores.pageState.showModal({type: MODAL_TYPES.ASSET_TAGS, asset: asset}); + pageState.showModal({type: MODAL_TYPES.ASSET_TAGS, asset: asset}); } /** @@ -575,7 +576,7 @@ export function manageAssetSettings(asset: AssetResponse) { modalType = MODAL_TYPES.LIBRARY_COLLECTION; } if (modalType) { - stores.pageState.showModal({ + pageState.showModal({ type: modalType, asset: asset, }); diff --git a/jsapp/js/assetStore.ts b/jsapp/js/assetStore.ts index 42c5e45e4d..27890c7302 100644 --- a/jsapp/js/assetStore.ts +++ b/jsapp/js/assetStore.ts @@ -52,8 +52,8 @@ class AssetStore extends Reflux.Store { * probability that it was already fetched from backend. * * NOTE: this is a copy of functionality that already exists in - * `stores.allAssets.whenLoaded`, but is a bit broken due to how `allAssets` - * was written (plus not typed). + * `stores.allAssets.whenLoaded` (that one is a bit broken due to how + * `allAssets` was written; plus it's not typed). */ whenLoaded(assetUid: string, callback: (foundAsset: AssetResponse) => void) { const foundAsset = this.getAsset(assetUid); diff --git a/jsapp/js/assetUtils.ts b/jsapp/js/assetUtils.ts index 43f4223537..18da8ebf7c 100644 --- a/jsapp/js/assetUtils.ts +++ b/jsapp/js/assetUtils.ts @@ -76,7 +76,9 @@ export function getOrganizationDisplayString(asset: AssetResponse | ProjectViewA * Returns the index of language or null if not found. */ export function getLanguageIndex(asset: AssetResponse, langString: string) { - let foundIndex = null; + // Return -1 instead of null as that would allow + // `getQuestionOrChoiceDisplayName` to defualt to xml names. + let foundIndex = -1; if ( Array.isArray(asset.summary?.languages) && @@ -224,6 +226,11 @@ export function getQuestionOrChoiceDisplayName( } if (questionOrChoice.label && Array.isArray(questionOrChoice.label)) { + // If the user hasn't made translations yet for a form language show + // the xml names instead of blank. + if (questionOrChoice.label[translationIndex] === null) { + return getRowName(questionOrChoice); + } return questionOrChoice.label[translationIndex]; } else if (questionOrChoice.label && !Array.isArray(questionOrChoice.label)) { // in rare cases the label could be a string diff --git a/jsapp/js/bemComponents.ts b/jsapp/js/bemComponents.ts index 79cd7ac1a4..dc1045a95d 100644 --- a/jsapp/js/bemComponents.ts +++ b/jsapp/js/bemComponents.ts @@ -6,17 +6,6 @@ import bem, {makeBem} from 'js/bem'; -// DEPRECATED: please don't use this component. From now on, we will only use -// the `Button` component (from `js/components/common/button`) as it covers -// all possible cases. -bem.Button = makeBem(null, 'mdl-button', 'button'); -bem.KoboButton = makeBem(null, 'kobo-button', 'button'); -bem.KoboLightButton = makeBem(null, 'kobo-light-button', 'button'); -bem.KoboTextButton = makeBem(null, 'kobo-text-button', 'button'); -// END DEPRECATED - -bem.KoboLightBadge = makeBem(null, 'kobo-light-badge', 'span'); - bem.KoboSelect = makeBem(null, 'kobo-select'); bem.KoboSelect__wrapper = makeBem(bem.KoboSelect, 'wrapper'); bem.KoboSelect__label = makeBem(bem.KoboSelect, 'label', 'span'); @@ -27,21 +16,14 @@ bem.KoboSelect__optionBadge = makeBem(bem.KoboSelect, 'option-badge'); bem.PageWrapper = makeBem(null, 'page-wrapper'); bem.PageWrapper__content = makeBem(bem.PageWrapper, 'content'); -bem.Loading = makeBem(null, 'loading'); -bem.Loading__inner = makeBem(bem.Loading, 'inner'); -bem.Loading__msg = makeBem(bem.Loading, 'msg'); - bem.EmptyContent = makeBem(null, 'empty-content', 'section'); bem.EmptyContent__icon = makeBem(bem.EmptyContent, 'icon', 'i'); bem.EmptyContent__title = makeBem(bem.EmptyContent, 'title', 'h1'); bem.EmptyContent__message = makeBem(bem.EmptyContent, 'message', 'p'); -bem.EmptyContent__button = makeBem(bem.EmptyContent, 'button', 'button'); bem.ServiceRow = makeBem(null, 'service-row'); bem.ServiceRow__column = makeBem(bem.ServiceRow, 'column'); -bem.ServiceRow__actionButton = makeBem(bem.ServiceRow, 'action-button', 'button'); bem.ServiceRow__linkOverlay = makeBem(bem.ServiceRow, 'link-overlay', 'a'); -bem.ServiceRowButton = makeBem(null, 'service-row-button', 'button'); bem.FormBuilder = makeBem(null, 'form-builder'); bem.FormBuilder__contents = makeBem(bem.FormBuilder, 'contents'); @@ -59,8 +41,6 @@ bem.FormBuilderHeader = makeBem(null, 'form-builder-header'); bem.FormBuilderHeader__row = makeBem(bem.FormBuilderHeader, 'row'); bem.FormBuilderHeader__cell = makeBem(bem.FormBuilderHeader, 'cell'); bem.FormBuilderHeader__item = makeBem(bem.FormBuilderHeader, 'item', 'span'); -bem.FormBuilderHeader__button = makeBem(bem.FormBuilderHeader, 'button', 'button'); -bem.FormBuilderHeader__close = makeBem(bem.FormBuilderHeader, 'close', 'button'); bem.FormMedia = makeBem(null, 'form-media'); bem.FormMedia__title = makeBem(bem.FormMedia, 'title'); @@ -105,16 +85,6 @@ bem.TableMeta__bulkOptions = makeBem(bem.TableMeta, 'bulk-options'); bem.CollectionsWrapper = makeBem(null, 'collections-wrapper'); -bem.CollectionNav = makeBem(null, 'collection-nav'); -bem.CollectionNav__search = makeBem(bem.CollectionNav, 'search'); -bem.CollectionNav__searchcriteria = makeBem(bem.CollectionNav, 'searchcriteria', 'ul'); -bem.CollectionNav__searchcriterion = makeBem(bem.CollectionNav, 'searchcriterion', 'li'); -bem.CollectionNav__actions = makeBem(bem.CollectionNav, 'actions'); -bem.CollectionNav__button = makeBem(bem.CollectionNav, 'button', 'button'); -bem.CollectionNav__link = makeBem(bem.CollectionNav, 'link', 'a'); -bem.CollectionNav__searchcancel = makeBem(bem.CollectionNav, 'searchcancel', 'i'); -bem.CollectionNav__searchicon = makeBem(bem.CollectionNav, 'searchicon', 'i'); - bem.FormView = makeBem(null, 'form-view'); // used in header.es6 bem.FormView__title = makeBem(bem.FormView, 'title'); @@ -127,7 +97,6 @@ bem.FormView__sidetabs = makeBem(bem.FormView, 'sidetabs'); bem.FormView__label = makeBem(bem.FormView, 'label'); bem.FormView__group = makeBem(bem.FormView, 'group'); bem.FormView__item = makeBem(bem.FormView, 'item'); -bem.FormView__iconButton = makeBem(bem.FormView, 'icon-button', 'button'); bem.FormView__row = makeBem(bem.FormView, 'row'); bem.FormView__cell = makeBem(bem.FormView, 'cell'); @@ -135,9 +104,6 @@ bem.FormView__cellLabel = makeBem(bem.FormView, 'cell-label'); bem.FormView__column = makeBem(bem.FormView, 'column'); bem.FormView__banner = makeBem(bem.FormView, 'banner'); -bem.FormView__link = makeBem(bem.FormView, 'link', 'a'); -bem.FormView__secondaryButtons = makeBem(bem.FormView, 'secondaryButtons'); -bem.FormView__secondaryButton = makeBem(bem.FormView, 'secondaryButton', 'button'); bem.FormView__reportButtons = makeBem(bem.FormView, 'reportButtons'); bem.FormView__form = makeBem(bem.FormView, 'form', 'form'); @@ -151,7 +117,6 @@ bem.ReportView__item = makeBem(bem.ReportView, 'item'); bem.ReportView__itemHeading = makeBem(bem.ReportView, 'itemHeading'); bem.ReportView__headingMeta = makeBem(bem.ReportView, 'headingMeta'); bem.ReportView__itemContent = makeBem(bem.ReportView, 'itemContent'); -bem.ReportView__headingButton = makeBem(bem.ReportView, 'headingButton', 'button'); bem.ReportView__chart = makeBem(bem.ReportView, 'chart'); bem.GraphSettings = makeBem(null, 'graph-settings'); @@ -201,9 +166,6 @@ bem.UserRow__perms = makeBem(bem.UserRow, 'perms'); bem.UserRow__perm = makeBem(bem.UserRow, 'perm'); bem.UserRow__editor = makeBem(bem.UserRow, 'editor'); -bem.uiPanel = makeBem(null, 'ui-panel'); -bem.uiPanel__body = makeBem(bem.uiPanel, 'body'); - bem.FormSidebarWrapper = makeBem(null, 'form-sidebar-wrapper'); bem.FormSidebar = makeBem(null, 'form-sidebar'); bem.FormSidebar__item = makeBem(bem.FormSidebar, 'item', 'a'); @@ -243,7 +205,6 @@ bem.Breadcrumbs__divider = makeBem(bem.Breadcrumbs, 'divider', 'i'); bem.AssetInfoBox = makeBem(null, 'asset-info-box'); bem.AssetInfoBox__column = makeBem(bem.AssetInfoBox, 'column'); bem.AssetInfoBox__cell = makeBem(bem.AssetInfoBox, 'cell'); -bem.AssetInfoBox__toggle = makeBem(bem.AssetInfoBox, 'toggle', 'button'); bem.PrintOnly = makeBem(null, 'print-only'); @@ -252,13 +213,11 @@ bem.ProjectDownloads__advancedView = makeBem(bem.ProjectDownloads, 'advanced-vie bem.ProjectDownloads__column = makeBem(bem.ProjectDownloads, 'column'); bem.ProjectDownloads__columnRow = makeBem(bem.ProjectDownloads, 'column-row'); bem.ProjectDownloads__title = makeBem(bem.ProjectDownloads, 'title', 'span'); -bem.ProjectDownloads__textButton = makeBem(bem.ProjectDownloads, 'text-button', 'button'); bem.ProjectDownloads__selectorRow = makeBem(bem.ProjectDownloads, 'selector-row'); bem.ProjectDownloads__anonymousRow = makeBem(bem.ProjectDownloads, 'anonymous-row'); bem.ProjectDownloads__legacyIframeWrapper = makeBem(bem.ProjectDownloads, 'legacy-iframe-wrapper'); bem.ProjectDownloads__submitRow = makeBem(bem.ProjectDownloads, 'submit-row', 'footer'); bem.ProjectDownloads__exportsSelector = makeBem(bem.ProjectDownloads, 'exports-selector'); -bem.ProjectDownloads__deleteSettingsButton = makeBem(bem.ProjectDownloads, 'delete-settings-button', 'button'); bem.ProjectDownloads__exportsCreator = makeBem(bem.ProjectDownloads, 'exports-creator'); bem.BackgroundAudioPlayer = makeBem(null, 'background-audio-player'); diff --git a/jsapp/js/components/RESTServices.scss b/jsapp/js/components/RESTServices.scss index 56e18b904b..33de8a3cae 100644 --- a/jsapp/js/components/RESTServices.scss +++ b/jsapp/js/components/RESTServices.scss @@ -53,14 +53,6 @@ } .rest-services-list__header-right {float: right;} - - .rest-services-list__header-back-button { - @extend .mdl-button, .mdl-button--icon; - - i { - vertical-align: middle; - } - } } // custom styles for RESTServicesForm diff --git a/jsapp/js/components/RESTServices/RESTServiceLogs.es6 b/jsapp/js/components/RESTServices/RESTServiceLogs.es6 index 4dc754de81..7be089aa4c 100644 --- a/jsapp/js/components/RESTServices/RESTServiceLogs.es6 +++ b/jsapp/js/components/RESTServices/RESTServiceLogs.es6 @@ -1,10 +1,9 @@ import React from 'react'; -import PropTypes from 'prop-types'; import autoBind from 'react-autobind'; import reactMixin from 'react-mixin'; import Reflux from 'reflux'; import alertify from 'alertifyjs'; -import {stores} from '../../stores'; +import pageState from 'js/pageState.store'; import bem from 'js/bem'; import LoadingSpinner from 'js/components/common/loadingSpinner'; import {actions} from '../../actions'; @@ -15,6 +14,7 @@ import { HOOK_LOG_STATUSES, MODAL_TYPES } from '../../constants'; +import Button from 'js/components/common/button'; export default class RESTServiceLogs extends React.Component { constructor(props){ @@ -174,7 +174,7 @@ export default class RESTServiceLogs extends React.Component { openSubmissionModal(log) { const currentAsset = this.currentAsset(); - stores.pageState.switchModal({ + pageState.switchModal({ type: MODAL_TYPES.SUBMISSION, sid: log.submission_id, asset: currentAsset, @@ -203,13 +203,16 @@ export default class RESTServiceLogs extends React.Component { renderHeader() { return (
    - - - {t('Back to REST Services')} - + + /> } ); diff --git a/jsapp/js/components/assetsTable/assetsTableRow.tsx b/jsapp/js/components/assetsTable/assetsTableRow.tsx index d7fc92f7aa..6b9eddf9d1 100644 --- a/jsapp/js/components/assetsTable/assetsTableRow.tsx +++ b/jsapp/js/components/assetsTable/assetsTableRow.tsx @@ -42,7 +42,7 @@ class AssetsTableRow extends React.Component { - + {this.props.asset.settings && this.props.asset.tag_string && this.props.asset.tag_string.length > 0 && diff --git a/jsapp/js/components/bigModal/bigModal.es6 b/jsapp/js/components/bigModal/bigModal.es6 index 2d161fe8ca..d0f3530d8b 100644 --- a/jsapp/js/components/bigModal/bigModal.es6 +++ b/jsapp/js/components/bigModal/bigModal.es6 @@ -4,7 +4,6 @@ import autoBind from 'react-autobind'; import Reflux from 'reflux'; import alertify from 'alertifyjs'; import {actions} from 'js/actions'; -import bem from 'js/bem'; import LoadingSpinner from 'js/components/common/loadingSpinner'; import Modal from 'js/components/common/modal'; import {stores} from 'js/stores'; @@ -30,6 +29,7 @@ import TranslationSettings from 'js/components/modalForms/translationSettings'; import TranslationTable from 'js/components/modalForms/translationTable'; // This should either be more generic or else be it's own component in the account directory. import MFAModals from './mfaModals'; +import pageState from 'js/pageState.store'; function getSubmissionTitle(props) { let title = t('Success!'); @@ -63,7 +63,7 @@ function getSubmissionTitle(props) { * To display a modal, you need to use `pageState` store with `showModal` method: * * ``` - * stores.pageState.showModal({ + * pageState.showModal({ * type: MODAL_TYPES.NEW_FORM * }); * ``` @@ -274,7 +274,7 @@ class BigModal extends React.Component { title: title, message: message, labels: {ok: t('Close'), cancel: t('Cancel')}, - onok: stores.pageState.hideModal, + onok: pageState.hideModal, oncancel: dialog.destroy, }; dialog.set(opts).show(); @@ -290,7 +290,7 @@ class BigModal extends React.Component { t('You will lose all unsaved changes.') ); } else { - stores.pageState.hideModal(); + pageState.hideModal(); } } @@ -380,17 +380,12 @@ class BigModal extends React.Component { ids={this.props.params.ids} isDuplicated={this.props.params.isDuplicated} duplicatedSubmission={this.props.params.duplicatedSubmission} - backgroundAudioUrl={this.props.params.backgroundAudioUrl} tableInfo={this.props.params.tableInfo || false} /> } { this.props.params.type === MODAL_TYPES.SUBMISSION && !this.state.sid &&
    - - - - - +
    } { this.props.params.type === MODAL_TYPES.TABLE_SETTINGS && diff --git a/jsapp/js/components/bigModal/mfaModals.scss b/jsapp/js/components/bigModal/mfaModals.scss index 2ebb5c1921..c503c476ce 100644 --- a/jsapp/js/components/bigModal/mfaModals.scss +++ b/jsapp/js/components/bigModal/mfaModals.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; $mfa-paragraph-spacing: sizes.$x18; diff --git a/jsapp/js/components/bigModal/mfaModals.tsx b/jsapp/js/components/bigModal/mfaModals.tsx index 4611b24b9b..acc36e3f74 100644 --- a/jsapp/js/components/bigModal/mfaModals.tsx +++ b/jsapp/js/components/bigModal/mfaModals.tsx @@ -1,6 +1,6 @@ import React from 'react'; import bem, {makeBem} from 'js/bem'; -import { observer } from 'mobx-react'; +import {observer} from 'mobx-react'; import sessionStore from 'js/stores/session'; import QRCode from 'qrcode.react'; import Button from 'js/components/common/button'; @@ -70,19 +70,27 @@ const MFAModals = class MFAModals extends React.Component< componentDidMount() { this.unlisteners.push( - mfaActions.activate.completed.listen(this.onMfaActivateCompleted.bind(this)), - mfaActions.confirmCode.completed.listen(this.onMfaCodesReceived.bind(this)), - mfaActions.regenerate.completed.listen(this.onMfaCodesReceived.bind(this)), + mfaActions.activate.completed.listen( + this.onMfaActivateCompleted.bind(this) + ), + mfaActions.confirmCode.completed.listen( + this.onMfaCodesReceived.bind(this) + ), + mfaActions.regenerate.completed.listen( + this.onMfaCodesReceived.bind(this) + ), mfaActions.deactivate.completed.listen(this.onMfaDeactivated.bind(this)), mfaActions.confirmCode.failed.listen(this.onCallFailed.bind(this)), mfaActions.regenerate.failed.listen(this.onCallFailed.bind(this)), - mfaActions.deactivate.failed.listen(this.onCallFailed.bind(this)), + mfaActions.deactivate.failed.listen(this.onCallFailed.bind(this)) ); } componentWillUnmount() { - this.unlisteners.forEach((clb) => {clb();}); + this.unlisteners.forEach((clb) => { + clb(); + }); } onMfaActivateCompleted(response: MfaActivatedResponse) { @@ -136,11 +144,9 @@ const MFAModals = class MFAModals extends React.Component< const keyFromBackend = this.props.qrCode || this.state.qrCode; if (keyFromBackend) { - return ( - keyFromBackend.split('=')[1].split('&')[0] - ); + return keyFromBackend.split('=')[1].split('&')[0]; } else { - return (t('Could not generate secret key')); + return t('Could not generate secret key'); } } @@ -176,16 +182,15 @@ const MFAModals = class MFAModals extends React.Component< this.setState({inputString: inputString}); } - changeStep( - evt: React.ChangeEvent, - newStep: ModalSteps - ) { + changeStep(evt: React.ChangeEvent, newStep: ModalSteps) { evt.preventDefault(); this.setState({currentStep: newStep}); } isTokenValid() { - return this.state.inputString !== null && this.state.inputString.length >= 1; + return ( + this.state.inputString !== null && this.state.inputString.length >= 1 + ); } downloadCodes() { @@ -211,7 +216,9 @@ const MFAModals = class MFAModals extends React.Component< // HACK FIX: since the header is seperate from the modal we do this // roundabout way of disabling the close icon disableCloseIcon() { - const closeIcon = document.getElementsByClassName('modal__x')[0] as HTMLElement; + const closeIcon = document.getElementsByClassName( + 'modal__x' + )[0] as HTMLElement; closeIcon.hidden = true; } @@ -219,11 +226,11 @@ const MFAModals = class MFAModals extends React.Component< return ( {t( - 'Two-factor authentication (2FA) verifies your identity using an authenticator application in addition to your usual password. ' - + 'We recommend enabling two-factor authentication for an additional layer of protection.' + 'Two-factor authentication (2FA) verifies your identity using an authenticator application in addition to your usual password. ' + + 'We recommend enabling two-factor authentication for an additional layer of protection.' )} - ) + ); } renderQRCodeStep() { @@ -233,37 +240,37 @@ const MFAModals = class MFAModals extends React.Component< {this.renderIntroText()} - {t('Scan QR code and enter the ##number##-digit token from the application').replace('##number##', String(envStore.data.mfa_code_length))} + {t( + 'Scan QR code and enter the ##number##-digit token from the application' + ).replace('##number##', String(envStore.data.mfa_code_length))} - + - {t('After scanning the QR code image, the authenticator app will display a ##number##-digit code that you can enter below.').replace('##number##', String(envStore.data.mfa_code_length))} + {t( + 'After scanning the QR code image, the authenticator app will display a ##number##-digit code that you can enter below.' + ).replace('##number##', String(envStore.data.mfa_code_length))} {t('No QR code?')} -   - ) => { this.changeStep(evt, 'manual'); @@ -299,24 +306,26 @@ const MFAModals = class MFAModals extends React.Component< {t( - 'The following recovery codes will help you access your account in case your authenticator app fails. These codes are unique and will not be stored in your Kobo account. ' - + 'This is your only opportunity to save them. Please download the file and keep it somewhere safe.' + 'The following recovery codes will help you access your account in case your authenticator app fails. These codes are unique and will not be stored in your Kobo account. ' + + 'This is your only opportunity to save them. Please download the file and keep it somewhere safe.' )} - {this.state.backupCodes && + {this.state.backupCodes && ( {this.state.backupCodes.map((backupCode, index) => ( -
  • {backupCode}
  • +
  • + {backupCode} +
  • ))}
    - } + )}
    @@ -351,34 +360,35 @@ const MFAModals = class MFAModals extends React.Component< return ( - - {this.renderIntroText()} - + {this.renderIntroText()} - {t('Enter this key into your authenticator app to generate a ##number##-digit token').replace('##number##', String(envStore.data.mfa_code_length))} + {t( + 'Enter this key into your authenticator app to generate a ##number##-digit token' + ).replace('##number##', String(envStore.data.mfa_code_length))} - - {this.getSecretKey()} - + {this.getSecretKey()} - {t('Once your authenticator app is set up, generate a ##number##-digit token and enter it in the field below.').replace('##number##', String(envStore.data.mfa_code_length))} + {t( + 'Once your authenticator app is set up, generate a ##number##-digit token and enter it in the field below.' + ).replace('##number##', String(envStore.data.mfa_code_length))} @@ -418,21 +428,24 @@ const MFAModals = class MFAModals extends React.Component< {/*This is safe as this step only shows if not on qr step*/} {this.props.modalType === 'regenerate' && - t('Please enter your ##number##-digit authenticator token to regenerate your backup codes.').replace('##number##', String(envStore.data.mfa_code_length)) - } + t( + 'Please enter your ##number##-digit authenticator token to regenerate your backup codes.' + ).replace('##number##', String(envStore.data.mfa_code_length))} {this.props.modalType !== 'regenerate' && - t('Please enter your ##number##-digit authenticator token to deactivate two-factor authentication.').replace('##number##', String(envStore.data.mfa_code_length)) - } + t( + 'Please enter your ##number##-digit authenticator token to deactivate two-factor authentication.' + ).replace('##number##', String(envStore.data.mfa_code_length))}
    @@ -455,9 +468,7 @@ const MFAModals = class MFAModals extends React.Component< size='l' isFullWidth label={t('Next')} - onClick={ - this.onSubmit.bind(this) - } + onClick={this.onSubmit.bind(this)} isDisabled={!this.isTokenValid()} /> @@ -479,23 +490,25 @@ const MFAModals = class MFAModals extends React.Component< {/*This is safe as this step only shows if on reconfigure or regenerate*/} {this.props.modalType === 'regenerate' && - t('Please note that generating new recovery codes will invalidate any previously generated codes.') - } + t( + 'Please note that generating new recovery codes will invalidate any previously generated codes.' + )} {this.props.modalType !== 'regenerate' && - t('Please note that in order to reconfigure two-factor authentication (2FA), the previous set up will first be deleted. Tokens or recovery codes from the previous set up will not be valid anymore.') - } + t( + 'Please note that in order to reconfigure two-factor authentication (2FA), the previous set up will first be deleted. Tokens or recovery codes from the previous set up will not be valid anymore.' + )} - {this.props.modalType === 'reconfigure' && + {this.props.modalType === 'reconfigure' && ( {t( - "Once your current 2FA has been deactivated, you'll be prompted to configure it again. If you cannot complete the process, 2FA will remain disabled for your account. " - + "In this case, you can enable it again at any time through the usual process." + "Once your current 2FA has been deactivated, you'll be prompted to configure it again. If you cannot complete the process, 2FA will remain disabled for your account. " + + 'In this case, you can enable it again at any time through the usual process.' )} - } + )}
    @@ -520,9 +533,15 @@ const MFAModals = class MFAModals extends React.Component< return ( - {t('Issues with the token')} + + {t('Issues with the token')} + - + @@ -577,6 +596,6 @@ const MFAModals = class MFAModals extends React.Component< return null; } } -} +}; export default (observer as any)(MFAModals); diff --git a/jsapp/js/components/common/assetName.scss b/jsapp/js/components/common/assetName.scss index d32ce519b9..14714fe6b7 100644 --- a/jsapp/js/components/common/assetName.scss +++ b/jsapp/js/components/common/assetName.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; .asset-name { &.asset-name--empty { diff --git a/jsapp/js/components/common/assetStatusBadge.tsx b/jsapp/js/components/common/assetStatusBadge.tsx index fad3c84d63..6e5edded60 100644 --- a/jsapp/js/components/common/assetStatusBadge.tsx +++ b/jsapp/js/components/common/assetStatusBadge.tsx @@ -1,9 +1,8 @@ import React from 'react'; import Badge from 'js/components/common/badge'; -import type {AssetResponse, ProjectViewAsset} from 'js/dataInterface'; interface AssetStatusBadgeProps { - asset: AssetResponse | ProjectViewAsset; + deploymentStatus?: string; } /** @@ -11,7 +10,8 @@ interface AssetStatusBadgeProps { * the project is draft, deployed, or archived. */ export default function AssetStatusBadge(props: AssetStatusBadgeProps) { - if (props.asset.deployment_status === 'archived') { + + if (props.deploymentStatus === 'archived') { return ( ); - } else if (props.asset.deployment_status === 'deployed') { + } else if (props.deploymentStatus === 'deployed') { return ( = new Map(); @@ -19,23 +24,31 @@ interface BadgeProps { size: BadgeSize; icon?: IconName; label: React.ReactNode; + /** + * Use it to ensure that the badge will always be display in whole. Without + * this (the default behaviour) the badge will take as much space as it gets, + * and hide overflowing content with ellipsis. + */ + disableShortening?: boolean; } export default function Badge(props: BadgeProps) { return ( -
    - {props.icon && +
    + {props.icon && ( - } - + )} + {props.label}
    ); } diff --git a/jsapp/js/components/common/button.scss b/jsapp/js/components/common/button.scss index 9aef57463e..ecea31c3b3 100644 --- a/jsapp/js/components/common/button.scss +++ b/jsapp/js/components/common/button.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; @use 'scss/mixins'; @use 'js/components/common/icon'; @@ -155,7 +155,7 @@ $button-border-radius: sizes.$x6; position: relative; // Needed for tooltips, pending state etc. font-weight: 500; text-decoration: none; - text-align: left; + text-align: initial; padding: 0; margin: 0; border-width: $button-border-width; @@ -219,6 +219,10 @@ $button-border-radius: sizes.$x6; justify-content: center; } +.k-button.k-button--upper-case { + text-transform: uppercase; +} + // blue button ↓ .k-button.k-button--color-blue.k-button--type-bare { @@ -233,87 +237,23 @@ $button-border-radius: sizes.$x6; @include button-full(colors.$kobo-blue, colors.$kobo-hover-blue); } -// light-blue button ↓ -// Note: `kobo-light-blue` needs a `kobo-dark-blue` text, so the light color is -// only being used for the `full` type. - -.k-button.k-button--color-light-blue.k-button--type-bare { - @include button-bare( - colors.$kobo-dark-blue, - color.adjust(colors.$kobo-dark-blue, $lightness: -5%) - ); -} - -.k-button.k-button--color-light-blue.k-button--type-frame { - @include button-frame(colors.$kobo-dark-blue); -} - -.k-button.k-button--color-light-blue.k-button--type-full { - @include button-full( - colors.$kobo-bg-blue, - color.adjust(colors.$kobo-dark-blue, $lightness: -5%), - colors.$kobo-dark-blue - ); -} - -// red button ↓ - -.k-button.k-button--color-red.k-button--type-bare { - @include button-bare( - colors.$kobo-red, - color.adjust(colors.$kobo-red, $lightness: -5%) - ); -} - -.k-button.k-button--color-red.k-button--type-frame { - @include button-frame(colors.$kobo-red); -} - -.k-button.k-button--color-red.k-button--type-full { - @include button-full( - colors.$kobo-red, - color.adjust(colors.$kobo-red, $lightness: -5%) - ); -} - -// storm button ↓ - -.k-button.k-button--color-storm.k-button--type-bare { - @include button-bare( - colors.$kobo-storm, - color.adjust(colors.$kobo-storm, $lightness: -5%) - ); -} - -.k-button.k-button--color-storm.k-button--type-frame { - @include button-frame(colors.$kobo-storm); -} - -.k-button.k-button--color-storm.k-button--type-full { - @include button-full( - colors.$kobo-storm, - color.adjust(colors.$kobo-storm, $lightness: -5%) - ); -} - -// light-storm button ↓ +// dark-red button ↓ -.k-button.k-button--color-light-storm.k-button--type-bare { +.k-button.k-button--color-dark-red.k-button--type-bare { @include button-bare( - colors.$kobo-light-storm, - color.adjust(colors.$kobo-light-storm, $lightness: -5%) + colors.$kobo-dark-red, + color.adjust(colors.$kobo-dark-red, $lightness: -5%) ); } -.k-button.k-button--color-light-storm.k-button--type-frame { - @include button-frame(colors.$kobo-light-storm); +.k-button.k-button--color-dark-red.k-button--type-frame { + @include button-frame(colors.$kobo-dark-red); } -.k-button.k-button--color-light-storm.k-button--type-full { +.k-button.k-button--color-dark-red.k-button--type-full { @include button-full( - colors.$kobo-light-storm, - color.adjust(colors.$kobo-light-storm, $lightness: -5%), - colors.$kobo-dark-blue + colors.$kobo-dark-red, + color.adjust(colors.$kobo-dark-red, $lightness: -5%) ); } diff --git a/jsapp/js/components/common/button.stories.tsx b/jsapp/js/components/common/button.stories.tsx index 742a2519b9..35a9c3e32f 100644 --- a/jsapp/js/components/common/button.stories.tsx +++ b/jsapp/js/components/common/button.stories.tsx @@ -9,10 +9,7 @@ const buttonTypes: ButtonType[] = ['bare', 'frame', 'full']; const buttonColors: ButtonColor[] = [ 'blue', - 'light-blue', - 'red', - 'storm', - 'light-storm', + 'dark-red', 'dark-blue', ]; diff --git a/jsapp/js/components/common/button.tsx b/jsapp/js/components/common/button.tsx index df0bfbaa1f..691a8be42d 100644 --- a/jsapp/js/components/common/button.tsx +++ b/jsapp/js/components/common/button.tsx @@ -6,6 +6,7 @@ import './button.scss'; import type {TooltipAlignment} from './tooltip'; import Tooltip from './tooltip'; import {useId} from 'js/hooks/useId.hook'; +import cx from 'classnames'; /** * Note: we use a simple TypeScript types here instead of enums, so we don't @@ -21,10 +22,7 @@ import {useId} from 'js/hooks/useId.hook'; export type ButtonType = 'bare' | 'frame' | 'full'; export type ButtonColor = | 'blue' - | 'light-blue' - | 'red' - | 'storm' - | 'light-storm' + | 'dark-red' | 'dark-blue'; /** @@ -72,8 +70,14 @@ export interface ButtonProps { isSubmit?: boolean; /** Simply changes the width. */ isFullWidth?: boolean; + /** + * Forces the label text to be uppercase. This is a legacy thing, as it is + * easier for us to have this here, rather than changing the labels (which + * requires new translations to be made). + */ + isUpperCase?: boolean; /** Additional class names. */ - classNames?: string[]; + className?: string; /** You don't need to pass the callback for `isSubmit` option. */ onClick?: (event: any) => void; 'data-cy'?: string; @@ -95,46 +99,10 @@ const Button = (props: ButtonProps) => { throw new Error('Button is missing a required properties: icon or label!'); } - let classNames: string[] = []; - - // Additional class names. - if (props.classNames) { - classNames = props.classNames; - } - - // Base class with mandatory ones. - classNames.push('k-button'); - classNames.push(`k-button--type-${props.type}`); - classNames.push(`k-button--color-${props.color}`); - - if (props.isPending) { - classNames.push('k-button--pending'); - } - - if (props.startIcon) { - classNames.push('k-button--has-start-icon'); - } - - // Ensures only one icon is being displayed. - if (!props.startIcon && props.endIcon) { - classNames.push('k-button--has-end-icon'); - } - - if (props.label) { - classNames.push('k-button--has-label'); - } - - if (props.isFullWidth) { - classNames.push('k-button--full-width'); - } - - const size = props.size; - classNames.push(`k-button--size-${size}`); - // Size depends on label being there or not - let iconSize = ButtonToIconAloneMap.get(size); + let iconSize = ButtonToIconAloneMap.get(props.size); if (props.label) { - iconSize = ButtonToIconMap.get(size); + iconSize = ButtonToIconMap.get(props.size); } // For the attributes that don't have a falsy value. @@ -157,7 +125,19 @@ const Button = (props: ButtonProps) => { const renderButton = () => ( ); diff --git a/jsapp/js/components/common/centeredMessage.component.tsx b/jsapp/js/components/common/centeredMessage.component.tsx new file mode 100644 index 0000000000..eb9fa656c2 --- /dev/null +++ b/jsapp/js/components/common/centeredMessage.component.tsx @@ -0,0 +1,20 @@ +import type {ReactElement} from 'react'; +import React from 'react'; +import styles from './centeredMessage.module.scss'; + +interface CenteredMessageProps { + message: ReactElement | string; +} + +/** + * A centered message component. + */ +export default function CenteredMessage(props: CenteredMessageProps) { + return ( +
    +
    + {props.message} +
    +
    + ); +} diff --git a/jsapp/js/components/common/centeredMessage.module.scss b/jsapp/js/components/common/centeredMessage.module.scss new file mode 100644 index 0000000000..6bbb5aa979 --- /dev/null +++ b/jsapp/js/components/common/centeredMessage.module.scss @@ -0,0 +1,16 @@ +.centeredMessage { + display: table; + vertical-align: middle; + height: 100%; + width: 100%; + font-size: 18px; + margin: 0; +} + +.centeredMessageInner { + display: table-cell; + vertical-align: middle; + text-align: center; + padding-left: 20px; + padding-right: 20px; +} diff --git a/jsapp/js/components/common/checkbox.scss b/jsapp/js/components/common/checkbox.scss index cb61f474f4..0733c89b32 100644 --- a/jsapp/js/components/common/checkbox.scss +++ b/jsapp/js/components/common/checkbox.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/_variables'; @use 'scss/sizes'; @use 'scss/mixins'; @@ -62,7 +62,7 @@ } .checkbox__input + .checkbox__label { - margin-left: sizes.$x6; + margin-inline-start: sizes.$x6; } .checkbox__input { diff --git a/jsapp/js/components/common/checkbox.tsx b/jsapp/js/components/common/checkbox.tsx index 88fd941815..b993f63e64 100644 --- a/jsapp/js/components/common/checkbox.tsx +++ b/jsapp/js/components/common/checkbox.tsx @@ -11,7 +11,8 @@ bem.Checkbox__label = makeBem(bem.Checkbox, 'label', 'span'); interface CheckboxProps { checked: boolean; disabled?: boolean; - onChange: (isChecked: boolean) => void; + /** `onChange` handler is obligatory, unless `onClick` is being provided */ + onChange?: (isChecked: boolean) => void; /** * Useful if you need to hijack the event, e.g. checkbox parent is clickable * and clicking the checkbox shouldn't cause that parent click - we can use @@ -26,7 +27,7 @@ interface CheckboxProps { /** A checkbox generic component. */ class Checkbox extends React.Component { - constructor(props: CheckboxProps){ + constructor(props: CheckboxProps) { if (typeof props.onChange !== 'function') { throw new Error('onChange callback missing!'); } @@ -35,7 +36,9 @@ class Checkbox extends React.Component { } onChange(evt: React.ChangeEvent) { - this.props.onChange(evt.currentTarget.checked); + if (this.props.onChange) { + this.props.onChange(evt.currentTarget.checked); + } } onClick(evt: React.MouseEvent | React.TouchEvent) { @@ -68,11 +71,9 @@ class Checkbox extends React.Component { data-cy={this.props['data-cy']} /> - {this.props.label && - - {this.props.label} - - } + {this.props.label && ( + {this.props.label} + )} ); diff --git a/jsapp/js/components/common/icon.scss b/jsapp/js/components/common/icon.scss index 27bf3670e0..4940d14172 100644 --- a/jsapp/js/components/common/icon.scss +++ b/jsapp/js/components/common/icon.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; $s-icon-xxs: 10px; $s-icon-xs: 14px; @@ -43,6 +43,10 @@ $s-icon-xl: 28px; color: colors.$kobo-red; } +.k-icon.k-icon--color-blue { + color: colors.$kobo-blue; +} + .k-icon.k-icon--color-amber { color: colors.$kobo-amber; } diff --git a/jsapp/js/components/common/icon.tsx b/jsapp/js/components/common/icon.tsx index b8ce777d37..51633a5afc 100644 --- a/jsapp/js/components/common/icon.tsx +++ b/jsapp/js/components/common/icon.tsx @@ -7,14 +7,15 @@ import './icon.scss'; * Check out `icon.scss` file for exact pixel values. */ export type IconSize = 'l' | 'm' | 's' | 'xl' | 'xs' | 'xxs'; -export type IconColor = 'red' | 'storm' | 'teal' | 'amber'; +export type IconColor = 'red' | 'storm' | 'teal' | 'amber' | 'blue'; const DefaultSize = 's'; interface IconProps { name: IconName; size?: IconSize; - classNames?: string[]; + /** Additional class names. */ + className?: string; /** * Useful if you need some color for the icon, and the color doesn't come from * parent component (e.g. Button). @@ -25,28 +26,21 @@ interface IconProps { /** * An icon component. */ -class Icon extends React.Component { - render() { - let classNames: string[] = []; - if ( - Array.isArray(this.props.classNames) && - typeof this.props.classNames[0] === 'string' - ) { - classNames = this.props.classNames; - } - - const size = this.props.size || DefaultSize; - classNames.push(`k-icon--size-${size}`); - - if (this.props.color) { - classNames.push(`k-icon--color-${this.props.color}`); - } - - classNames.push('k-icon'); - classNames.push(`k-icon-${this.props.name}`); - - return ; +export default function Icon(props: IconProps) { + let classNames: string[] = []; + if (props.className) { + classNames.push(props.className); } -} -export default Icon; + const size = props.size || DefaultSize; + classNames.push(`k-icon--size-${size}`); + + if (props.color) { + classNames.push(`k-icon--color-${props.color}`); + } + + classNames.push('k-icon'); + classNames.push(`k-icon-${props.name}`); + + return ; +} diff --git a/jsapp/js/components/common/inlineMessage.scss b/jsapp/js/components/common/inlineMessage.scss index 1e5c20867c..669cfa6ac3 100644 --- a/jsapp/js/components/common/inlineMessage.scss +++ b/jsapp/js/components/common/inlineMessage.scss @@ -1,19 +1,17 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; @use 'scss/mixins'; .k-inline-message { @include mixins.centerRowFlex; - + align-items: flex-start; + gap: 12px; margin: 0; padding: sizes.$x12 sizes.$x24; width: 100%; border-radius: sizes.$x6; - color: colors.$kobo-gray-40; - - .k-icon { - margin-right: sizes.$x12; - } + color: colors.$kobo-gray-24; + line-height: sizes.$x22; a { text-decoration: underline; @@ -35,40 +33,28 @@ .k-inline-message--type-default { background-color: colors.$kobo-gray-96; - - .k-icon { - color: colors.$kobo-gray-65; - } + .k-icon {color: colors.$kobo-gray-65;} } .k-inline-message--type-error { background-color: colors.$kobo-light-red; - - .k-icon { - color: colors.$kobo-red; - } + .k-icon {color: colors.$kobo-red;} } .k-inline-message--type-success { background-color: colors.$kobo-light-teal; - - .k-icon { - color: colors.$kobo-teal; - } + .k-icon {color: colors.$kobo-teal;} } .k-inline-message--type-warning { background-color: colors.$kobo-light-amber; - - .k-icon { - color: colors.$kobo-amber; - } + .k-icon {color: colors.$kobo-amber;} } // We need a bit stronger specificity here .k-inline-message p.k-inline-message__message { margin: 0; - text-align: left; + text-align: initial; font-size: sizes.$x14; - line-height: sizes.$x22; + line-height: inherit; } diff --git a/jsapp/js/components/common/inlineMessage.tsx b/jsapp/js/components/common/inlineMessage.tsx index dde59b019c..75dc919add 100644 --- a/jsapp/js/components/common/inlineMessage.tsx +++ b/jsapp/js/components/common/inlineMessage.tsx @@ -1,53 +1,42 @@ -import type {ReactElement} from 'react'; import React from 'react'; +import cx from 'classnames'; import type {IconName} from 'jsapp/fonts/k-icons'; import Icon from 'js/components/common/icon'; import './inlineMessage.scss'; +/** Influences the background color and the icon color */ export type InlineMessageType = 'default' | 'error' | 'success' | 'warning'; interface InlineMessageProps { type: InlineMessageType; + message: React.ReactNode; icon?: IconName; - message: ReactElement | string; /** Additional class names. */ - classNames?: string[]; + className?: string; 'data-cy'?: string; } /** - * An inline message component. + * An inline message component. It's a rounded corners box with a background and + * an optional icon displayed on the left side. */ -class InlineMessage extends React.Component { - render() { - let classNames: string[] = []; - - // Additional class names. - if (this.props.classNames) { - classNames = this.props.classNames; - } - - // Base class with mandatory ones. - classNames.push('k-inline-message'); - classNames.push(`k-inline-message--type-${this.props.type}`); - - return ( -
    - {this.props.icon && - - } - - {this.props.message && -

    - {this.props.message} -

    - } -
    - ); - } +export default function InlineMessage(props: InlineMessageProps) { + return ( +
    + {props.icon && + + } + +

    + {props.message} +

    +
    + ); } - -export default InlineMessage; diff --git a/jsapp/js/components/common/koboDropdown.tsx b/jsapp/js/components/common/koboDropdown.tsx index 6aaa82ddd4..28b2732ded 100644 --- a/jsapp/js/components/common/koboDropdown.tsx +++ b/jsapp/js/components/common/koboDropdown.tsx @@ -16,7 +16,8 @@ export type KoboDropdownPlacement = const DEFAULT_PLACEMENT: KoboDropdownPlacement = 'down-center'; interface KoboDropdownProps { - placement: KoboDropdownPlacement; + /** Defaults to DEFAULT_PLACEMENT :wink: */ + placement?: KoboDropdownPlacement; isRequired?: boolean; /** Disables the dropdowns trigger, thus disallowing opening dropdown. */ isDisabled?: boolean; @@ -26,8 +27,8 @@ interface KoboDropdownProps { /** The content of dropdown, anything's allowed. */ menuContent: React.ReactNode; /** - * Optional name value useful for styling and `menuVisibilityChange` action, - * ends up in `data-name` attribut.e + * Name useful for styling and `menuVisibilityChange` action, it ends up in + * the `data-name` attribute. */ name: string; 'data-cy'?: string; @@ -40,7 +41,7 @@ interface KoboDropdownState { } interface AdditionalWrapperAttributes { - 'data-name'?: string; + 'data-name': string; 'data-cy'?: string; } @@ -228,12 +229,10 @@ export default class KoboDropdown extends React.Component< } render() { - const additionalWrapperAttributes: AdditionalWrapperAttributes = {}; - - if (this.props.name) { + const additionalWrapperAttributes: AdditionalWrapperAttributes = { // We use `data-name` attribute to allow any character in the name. - additionalWrapperAttributes['data-name'] = this.props.name; - } + ['data-name']: this.props.name, + }; if (this.props['data-cy']) { additionalWrapperAttributes['data-cy'] = this.props['data-cy']; diff --git a/jsapp/js/components/common/koboImage.tsx b/jsapp/js/components/common/koboImage.tsx index e728c02d75..e35b7dcbf8 100644 --- a/jsapp/js/components/common/koboImage.tsx +++ b/jsapp/js/components/common/koboImage.tsx @@ -49,7 +49,7 @@ class KoboImage extends React.Component { return ( {this.state.isLoading && - + } {!this.state.isLoading && diff --git a/jsapp/js/components/common/koboRange.scss b/jsapp/js/components/common/koboRange.scss index 4e3513d169..fba1723f2b 100644 --- a/jsapp/js/components/common/koboRange.scss +++ b/jsapp/js/components/common/koboRange.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @mixin rangeTrack() { -webkit-appearance: none; diff --git a/jsapp/js/components/common/koboSelect.scss b/jsapp/js/components/common/koboSelect.scss index 330e02c037..b7f198eb71 100644 --- a/jsapp/js/components/common/koboSelect.scss +++ b/jsapp/js/components/common/koboSelect.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/mixins'; @use 'scss/sizes'; @use 'js/components/common/button'; @@ -29,7 +29,7 @@ $k-select-menu-padding: sizes.$x6; @include mixins.centerRowFlex; justify-content: space-between; font-weight: 400; - text-align: left; + text-align: initial; border-width: button.$button-border-width; border-style: solid; border-color: transparent; @@ -89,9 +89,23 @@ $k-select-menu-padding: sizes.$x6; .k-select__option { @include mixins.buttonReset; + font-weight: 400; + position: relative; + + &:hover, + &.k-select__option--selected { + color: colors.$kobo-gray-24; + background-color: colors.$kobo-gray-96; + } + + &:focus-visible { + @include mixins.default-ui-focus; + // Needed so that option--selected never appears above focus styles + z-index: 1; + } .k-icon { - color: colors.$kobo-gray-85; + color: inherit; } } @@ -103,7 +117,7 @@ $k-select-menu-padding: sizes.$x6; height: $k-select-option-height; color: colors.$kobo-gray-40; padding: 0 #{sizes.$x16 - sizes.$x2}; - text-align: left; + text-align: initial; } .k-select__menu-message { @@ -135,23 +149,6 @@ $k-select-menu-padding: sizes.$x6; } } -.k-select__option { - font-weight: 500; - position: relative; - - &:hover, - &.k-select__option--selected { - color: colors.$kobo-gray-24; - background-color: colors.$kobo-gray-96; - } - - &:focus-visible { - @include mixins.default-ui-focus; - // Needed so that option--selected never appears above focus styles - z-index: 1; - } -} - .k-select__option label, .k-select__trigger label { @include mixins.textEllipsis; diff --git a/jsapp/js/components/common/koboSelect.tsx b/jsapp/js/components/common/koboSelect.tsx index 05a7b583be..83d5a2dbe4 100644 --- a/jsapp/js/components/common/koboSelect.tsx +++ b/jsapp/js/components/common/koboSelect.tsx @@ -11,6 +11,7 @@ import {ButtonToIconMap} from 'js/components/common/button'; import KoboDropdown from 'js/components/common/koboDropdown'; import koboDropdownActions from 'js/components/common/koboDropdownActions'; import './koboSelect.scss'; +import type {KoboDropdownPlacement} from 'js/components/common/koboDropdown'; // We can't use "kobo-select" as it is already being used for custom styling of `react-select`. bem.KoboSelect = makeBem(null, 'k-select'); @@ -57,6 +58,7 @@ interface KoboSelectProps { * Sizes are generally the same as in button component so we use same type. */ size: ButtonSize; + placement?: KoboDropdownPlacement; /** Without this option select always need the `selectedOption`. */ isClearable?: boolean; /** This option displays a text box filtering options when opened. */ @@ -190,7 +192,7 @@ class KoboSelect extends React.Component { if (foundSelectedOption) { return ( - + {foundSelectedOption.icon && { } @@ -243,7 +245,7 @@ class KoboSelect extends React.Component { } @@ -296,6 +298,7 @@ class KoboSelect extends React.Component { this.props.selectedOption === option.value ), }} + dir='auto' > {option.icon && } @@ -348,7 +351,7 @@ class KoboSelect extends React.Component { { const inputProps: InnerInputProps = { placeholder: this.props.placeholder || DEFAULT_PLACEHOLDER, 'data-cy': this.props['data-cy'], + dir: 'auto', }; if (this.props.label) { diff --git a/jsapp/js/components/common/loadingSpinner.module.scss b/jsapp/js/components/common/loadingSpinner.module.scss new file mode 100644 index 0000000000..33ce58f262 --- /dev/null +++ b/jsapp/js/components/common/loadingSpinner.module.scss @@ -0,0 +1,99 @@ +@use 'scss/colors'; +@use 'scss/_variables'; + +// Loading messages +.loading { + display: table; + vertical-align: middle; + height: 100%; + width: 100%; + font-size: 18px; + opacity: 0.8; +} + +// Adjust spinning icon position for the regular spinner +.loadingTypeRegular { + i:first-child { + vertical-align: -7px; + display: inline-block; + } +} + +// Moves message a bit off center to make it appear centered (ellipsis causes +// this optical illusion) +.loadingHasDefaultMessage { + .loadingMessage { + padding-left: 5px; + } +} + +.loadingMessage { + margin-top: 10px; + display: block; +} + +.loadingInner { + display: table-cell; + vertical-align: middle; + text-align: center; + padding-left: 20px; + padding-right: 20px; + overflow: hidden; // avoids spinner icon overflowing scrollable areas + + code { + margin: 20px; + padding: 15px; + font-size: 13px; + display: block; + background: colors.$kobo-white; + width: 80%; + max-height: 300px; + overflow: auto; + word-wrap: break-word; + text-align: initial; + } +} + +@keyframes rotate { + from {transform: rotateZ(360deg);} + to {transform: rotateZ(0deg);} +} + +$spinner-size: 64px; +$spinner-line-size: 10px; +$spinner-mask-size: $spinner-size * 0.5 - $spinner-line-size; + +.bigSpinner { + display: block; + margin: 0 auto; + position: relative; + height: $spinner-size; + width: $spinner-size; + border-radius: 50%; + background: conic-gradient(#{colors.$kobo-blue}, transparent); + animation: rotate 1s linear infinite; + mask-image: radial-gradient(circle, transparent $spinner-mask-size, black 33%); +} + +.bigSpinner::before, +.bigSpinner::after { + content: ''; + position: absolute; + border-radius: 50%; +} + +.bigSpinner::before { + width: $spinner-size - (2 * $spinner-line-size); + height: $spinner-size - (2 * $spinner-line-size); + top: $spinner-line-size; + left: $spinner-line-size; + background-color: colors.$kobo-white; +} + +.bigSpinner::after { + height: $spinner-line-size; + width: $spinner-line-size; + background-color: colors.$kobo-blue; + top: 0; + left: ($spinner-size - $spinner-line-size) * 0.5; +} diff --git a/jsapp/js/components/common/loadingSpinner.scss b/jsapp/js/components/common/loadingSpinner.scss deleted file mode 100644 index 2ea9b26323..0000000000 --- a/jsapp/js/components/common/loadingSpinner.scss +++ /dev/null @@ -1,49 +0,0 @@ -@use '~kobo-common/src/styles/colors'; -@use "scss/_variables"; - -// Loading messages -.loading { - display: table; - vertical-align: middle; - height: 100%; - width: 100%; - font-size: 18px; - opacity: 0.8; - - i:first-child { - margin-right: 10px; - vertical-align: -7px; - display: inline-block; - } - - .loading__inner { - display: table-cell; - vertical-align: middle; - text-align: center; - padding-left: 20px; - padding-right: 20px; - overflow: hidden; // avoids spinner icon overflowing scrollable areas - - code { - margin: 20px; - padding: 15px; - font-size: 13px; - display: block; - background: colors.$kobo-white; - width: 80%; - max-height: 300px; - overflow: auto; - word-wrap: break-word; - text-align: left; - } - - .pro-tip { - font-size: variables.$base-font-size; - margin-top: 30px; - } - } - - .loading__msg { - padding-top: 10px; - } -} diff --git a/jsapp/js/components/common/loadingSpinner.stories.tsx b/jsapp/js/components/common/loadingSpinner.stories.tsx new file mode 100644 index 0000000000..a22f61a12e --- /dev/null +++ b/jsapp/js/components/common/loadingSpinner.stories.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type {ComponentStory, ComponentMeta} from '@storybook/react'; +import LoadingSpinner from './loadingSpinner'; +import type {LoadingSpinnerType} from './loadingSpinner'; + +const spinnerTypes: LoadingSpinnerType[] = ['regular', 'big']; + +export default { + title: 'common/LoadingSpinner', + component: LoadingSpinner, + argTypes: { + type: { + description: 'Type of LoadingSpinner', + options: spinnerTypes, + control: 'radio', + }, + message: { + options: [ + undefined, + false, + 'Please wait things are coming…', + 'Please wait until this process finishes, as there are a lot of things going on in the background. But since you started reading this, most probably the whole thing have already finished…', + ], + description: + 'Displayed underneath the animating spinner. If custom message is not provided, default message will be displayed. If `false` is passed, message will not be displayed.', + control: 'select', + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + +); + +export const Regular = Template.bind({}); +Regular.args = { + type: 'regular', + message: 'To infinity and beyond…', +}; + +export const Big = Template.bind({}); +Big.args = { + type: 'big', + message: 'Working on it…', +}; diff --git a/jsapp/js/components/common/loadingSpinner.tsx b/jsapp/js/components/common/loadingSpinner.tsx index 11dac1e158..4d0e6abfbd 100644 --- a/jsapp/js/components/common/loadingSpinner.tsx +++ b/jsapp/js/components/common/loadingSpinner.tsx @@ -1,36 +1,55 @@ import React from 'react'; -import bem from 'js/bem'; -import './loadingSpinner.scss'; +import cx from 'classnames'; +import styles from './loadingSpinner.module.scss'; import Icon from 'js/components/common/icon'; +export type LoadingSpinnerType = 'regular' | 'big'; + interface LoadingSpinnerProps { - message?: string; + /** Changes the looks of the spinner animation. */ + type?: LoadingSpinnerType; /** - * Most of the times we want a message, either custom or default one, but - * sometimes we want just the spinner. We need a boolean to hide it, because - * component has a fallback message. + * There is a default message if nothing is provided. If you want to hide + * the message completely, pass `false`. */ - hideMessage?: boolean; - hideSpinner?: boolean; + message?: string | boolean; 'data-cy'?: string; + /** Additional class names. */ + className?: string; } -export default class LoadingSpinner extends React.Component< - LoadingSpinnerProps, - {} -> { - render() { - const message = this.props.message || t('loading…'); +/** + * Displays a spinner animation above a customizable yet optional message. + */ +export default function LoadingSpinner(props: LoadingSpinnerProps) { + const spinnerType: LoadingSpinnerType = props.type || 'regular'; + const message = props.message || t('loading…'); + + return ( +
    ` directly inside + // ``, see `_kobo.form-view.scss` for details. + // DO NOT USE, if needed go for the custom `className` prop. + loadingSpinner: true, + [styles.loading]: true, + [styles.loadingTypeRegular]: spinnerType === 'regular', + [styles.loadingHasDefaultMessage]: props.message === undefined, + }, props.className)} + data-cy={props['data-cy']} + > +
    + {spinnerType === 'regular' && ( + + )} + + {spinnerType === 'big' && } - return ( - - - {!this.props.hideSpinner && ( - - )} - {!this.props.hideMessage && message} - - - ); - } + {props.message !== false && ( + {message} + )} +
    +
    + ); } diff --git a/jsapp/js/components/common/miniAudioPlayer.scss b/jsapp/js/components/common/miniAudioPlayer.scss index 5e655d0a94..6073ca7403 100644 --- a/jsapp/js/components/common/miniAudioPlayer.scss +++ b/jsapp/js/components/common/miniAudioPlayer.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; @use 'js/components/common/button'; diff --git a/jsapp/js/components/common/modal.scss b/jsapp/js/components/common/modal.scss index a01f9deaa3..c3e09d6d0f 100644 --- a/jsapp/js/components/common/modal.scss +++ b/jsapp/js/components/common/modal.scss @@ -1,7 +1,9 @@ -@use '~kobo-common/src/styles/colors'; -@use "scss/sizes"; -@use "scss/libs/_mdl"; -@use "scss/_variables"; +@use 'scss/colors'; +@use 'scss/breakpoints'; +@use 'scss/sizes'; +@use 'scss/libs/_mdl'; +@use 'scss/_variables'; +@use 'scss/mixins'; $z-modal-backdrop: 1101; $z-enketo-iframe-icon: 1100; @@ -194,144 +196,6 @@ $z-modal-x: 10; // custom parts and overrides // ----------------------------------------------------------------------------- -.modal.modal-submission { - .modal__header { - background-color: colors.$kobo-gray-96; - color: mdl.$layout-text-color; - } - - .form-modal { - text-align: right; - padding-bottom: 20px; - } - - .mdl-button--raised + .mdl-button--icon { - margin-left: 30px; - overflow: visible; - } - - .form-modal__group { - display: flex; - justify-content: space-between; - margin-bottom: 20px; - - .submission-modal-dropdowns:only-child { - width: 100%; - } - - .submission-modal-dropdowns { - width: 50%; - display: flex; - justify-content: space-between; - - .switch--label-language, - .switch--validation-status { - width: 45%; - text-align: left; - - label, .kobo-select { - display: inline-block; - vertical-align: middle; - } - - label { - margin-right: 12px; - } - } - - .switch--validation-status { - width: 100%; - text-align: right; - .kobo-select { - text-align: left; - } - } - - .switch--label-language + .switch--validation-status { - width: 50%; - } - - .kobo-select input { - min-width: 100px; - } - - .kobo-select { - min-width: 120px; - } - } - } - - .submission-pager { - a { - display: inline-block; - cursor: pointer; - - &:first-child { - padding-left: 0; - } - - .k-icon { - display: inline-block; - vertical-align: middle; - } - } - } - - .submission-actions { - .checkbox { - display: inline-block; - vertical-align: middle; - margin-right: 40px; - } - - .checkbox__label { - white-space: nowrap; - } - - .mdl-button--icon { - padding-left: 6px; - padding-right: 6px; - - &:not(:first-child) { - margin-left: 10px; - } - } - } - - .submission-duplicate__actions { - @extend .submission-actions; - margin: auto; - a { - width: 120px; - } - } - - .submission-duplicate__button { - margin-left: 12px; - } - - .submission-duplicate__text { - max-width: 60%; - text-align: center; - margin: auto; - margin-bottom: 24px; - } - - .submission-duplicate__header { - @extend .submission-duplicate__text; - margin-bottom: 12px; - color: colors.$kobo-blue; - } - - .submission--warning { - margin-bottom: 30px; - padding: 10px; - background: colors.$kobo-gray-96; - text-align: center; - line-height: 1em; - } -} - $modal-custom-header-height: sizes.$x60; // TODO: Make a better generic modal component @@ -423,24 +287,6 @@ $modal-custom-header-height: sizes.$x60; font-size: 20px; } } - - .kobo-light-button { - border-width: 2px; - line-height: 12px; - - i.k-icon { - margin-right: -3px; - } - - i.k-icon-download { - font-weight: 800; - margin-left: 3px; - } - - &:not(:last-child) { - margin-right: 12px; - } - } } } @@ -496,7 +342,7 @@ $modal-custom-header-height: sizes.$x60; .modal & { width: 100%; - @media screen and (min-width: 768px) { + @media screen and (min-width: breakpoints.$b768) { min-width: 600px; } } @@ -507,7 +353,7 @@ $modal-custom-header-height: sizes.$x60; .intro { margin-bottom: 20px; - text-align: left; + text-align: initial; } .form-modal__item[disabled] { @@ -534,7 +380,7 @@ $modal-custom-header-height: sizes.$x60; min-height: 120px; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.25); - @media screen and (min-width: 768px) { + @media screen and (min-width: breakpoints.$b768) { margin: $buttons-spacing; padding: 2*$buttons-spacing; width: calc(50% - #{2*$buttons-spacing}); @@ -603,32 +449,14 @@ $modal-custom-header-height: sizes.$x60; .form-modal__item--http-headers { .form-modal__item--http-header-row { + @include mixins.centerRowFlex; + gap: 10px; + margin-top: 10px; input[type="text"] { - width: calc(50% - 20px); background-color: rgba(colors.$kobo-gray-24, 0.05); - padding-left: 5px; - padding-right: 5px; - - &:not(:first-child) { - margin-left: 10px; - } - } - } - - .http-header-row-remove { - height: 30px; - min-height: 30px; - line-height: 30px; - width: 30px; - vertical-align: top; - padding: 0; - &:hover {color: colors.$kobo-red;} - - .k-icon { - font-size: 1.2em; - vertical-align: middle; + padding: 5px; } } } @@ -661,28 +489,6 @@ $modal-custom-header-height: sizes.$x60; flex-direction: row; align-items: center; } - - .form-view__cell--translation-name .form-view__icon-button { - opacity: 0; - visibility: hidden; - transition: 250ms; - } - - &:hover .form-view__cell--translation-name .form-view__icon-button { - opacity: 1; - visibility: visible; - - // Hide the on hover button completely if disabled - &:disabled { - display: none; - } - } - - .form-view__icon-button.right-tooltip { - &:disabled { - color: colors.$kobo-gray-65; - } - } } .form-view__cell--translation-actions { @@ -701,39 +507,9 @@ $modal-custom-header-height: sizes.$x60; border-top: 1px solid colors.$kobo-gray-92; } - .form-view__cell--add-language-form, - .form-view__cell--update-language-form { - background: colors.$kobo-gray-96; - padding: 15px; - position: relative; - - .form-view__link--close { - position: absolute; - right: 0px; - top: 6px; - - i { - font-size: 18px; - margin: 3px; - } - } - } - .form-view__cell--add-language-form { margin-top: 20px; } - - .form-view__form--add-language-fields { - display: flex; - justify-content: space-between; - - .form-view__cell { - &:not(:first-child) {margin-left: 10px;} - &.form-view__cell--lang-name {flex: 3;} - &.form-view__cell--lang-code {flex: 2;} - &.form-view__cell--submit-button {padding-top: 10px;} - } - } } .form-modal--translation-table { @@ -753,14 +529,10 @@ $modal-custom-header-height: sizes.$x60; .form-view__cell--add-language-form, .form-view__cell--update-language-form { - background: colors.$kobo-gray-96; - padding: 15px; - position: relative; - - .form-view__link--close { + .add-language-form-close { position: absolute; - right: 0px; - top: 6px; + right: 5px; + top: 5px; i { font-size: 18px; @@ -774,18 +546,6 @@ $modal-custom-header-height: sizes.$x60; margin-left: auto; } - .form-view__form--add-language-fields { - display: flex; - justify-content: space-between; - - .form-view__cell { - &:not(:first-child) {margin-left: 10px;} - &.form-view__cell--lang-name {flex: 3;} - &.form-view__cell--lang-code {flex: 2;} - &.form-view__cell--submit-button {padding-top: 10px;} - } - } - .ReactTable { width: 100%; @@ -837,23 +597,99 @@ $modal-custom-header-height: sizes.$x60; } } +.form-modal--translation-settings, +.form-modal--translation-table { + .form-view__form--add-language-fields { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 10px; + + .form-view__cell { + &.form-view__cell--lang-name {flex: 3;} + &.form-view__cell--lang-code {flex: 2;} + } + } + + .form-view__cell--add-language-form, + .form-view__cell--update-language-form { + background: colors.$kobo-gray-96; + padding: 15px; + position: relative; + + .add-language-form-close { + position: absolute; + right: 5px; + top: 5px; + + i { + font-size: 18px; + margin: 3px; + } + } + } +} + .form-view__cell--encrypt-key { padding-bottom: 12px; } -.encrypt-help { - color: colors.$kobo-gray-40; - background: transparent; - border: 0; - font-size: 28px; +.encrypt-form-footer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-end; + gap: 5px; +} + +.encrypt-form-footer-left { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; +} + +// Tab button - styles were copied from the deprecated `mdl-button` +.legacy-modal-tab-button { + text-decoration: none; + text-align: center; + font-weight: normal; + font-size: variables.$base-font-size; + letter-spacing: 0; + margin: 0; + border: none; vertical-align: middle; + background: transparent; + position: relative; + display: inline-block; + overflow: hidden; cursor: pointer; -} + color: colors.$kobo-white; + opacity: 0.7; + border-radius: 0; + border-bottom: 2px solid transparent; + text-transform: uppercase; + height: 50px; + line-height: 50px; + padding: 0 32px; + + &::-moz-focus-inner { + border: 0; + } -.remove-encryption { - float: right; + &:hover { + opacity: 1; + color: colors.$kobo-white; + } + + &.legacy-modal-tab-button--active { + opacity: 1; + border-bottom: 2px solid white; + } } -@media screen and (max-width: 480px) { + +// TODO: rework all these media queries to mobile first +@media screen and (width < 480px) { .table-media-preview-header { margin-right: 0 !important; display: block !important; @@ -861,31 +697,23 @@ $modal-custom-header-height: sizes.$x60; .table-media-preview-header__title { display: flex; } - - .kobo-light-button { - display: block; - width: 90%; - margin-left: auto; - margin-right: auto; - margin-top: 12px; - } } } -@media screen and (max-width: 767px) { +@media screen and (width < breakpoints.$b768) { .modal.modal--open { min-width: 90%; max-width: 90%; } } -@media screen and (max-width: 1175px) { +@media screen and (width < 1175px) { .form-media__upload-url { width: 100%; } } -@media screen and (max-width: 597px) { +@media screen and (width < 597px) { .form-media__upload-url { width: 100%; padding-left: 0px; @@ -900,7 +728,7 @@ $modal-custom-header-height: sizes.$x60; // FIXME: Due to the need to hardcode height of mfa modals, we allow scrolling // on mobile until we standardize all modals (see comment on mfaModals.scss) -@media screen and (max-height: 800px) { +@media screen and (height < 800px) { .modal.modal--mfa-setup { .modal__body { overflow-y: auto; diff --git a/jsapp/js/components/common/multiCheckbox.scss b/jsapp/js/components/common/multiCheckbox.scss index 526c1d4c22..0306bd7bbb 100644 --- a/jsapp/js/components/common/multiCheckbox.scss +++ b/jsapp/js/components/common/multiCheckbox.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/sizes'; .multi-checkbox { @@ -6,6 +6,8 @@ height: auto; width: 100%; overflow: hidden auto; + text-align: initial; + padding: 0; } .multi-checkbox__item:not(:last-child) { diff --git a/jsapp/js/components/common/multiCheckbox.tsx b/jsapp/js/components/common/multiCheckbox.tsx index b55bf7815e..67c6ea16c8 100644 --- a/jsapp/js/components/common/multiCheckbox.tsx +++ b/jsapp/js/components/common/multiCheckbox.tsx @@ -24,35 +24,39 @@ interface MultiCheckboxProps { disabled?: boolean; /** Returns whole list whenever any item changes */ onChange: (items: MultiCheckboxItem[]) => void; + /** Additional class names. */ + className?: string; } /** * A MultiCheckbox generic component. * Use optional `bem.MultiCheckbox__wrapper` to display a frame around it. */ -class MultiCheckbox extends React.Component { - onChange(itemIndex: number, isChecked: boolean) { - const updatedList = this.props.items; +export default function MultiCheckbox (props: MultiCheckboxProps) { + function onChange(itemIndex: number, isChecked: boolean) { + const updatedList = props.items; updatedList[itemIndex].checked = isChecked; - this.props.onChange(updatedList); + props.onChange(updatedList); } - render() { - return ( - - {this.props.items.map((item, itemIndex) => ( - - - - ))} - - ); - } + return ( + + {props.items.map((item, itemIndex) => ( + + { + onChange(itemIndex, isChecked); + }} + label={item.label} + /> + + ))} + + ); } - -export default MultiCheckbox; diff --git a/jsapp/js/components/common/radio.scss b/jsapp/js/components/common/radio.scss index ea55470497..02ea3fbeab 100644 --- a/jsapp/js/components/common/radio.scss +++ b/jsapp/js/components/common/radio.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/_variables'; @use 'scss/sizes'; @use 'scss/mixins'; @@ -30,7 +30,7 @@ } .radio__input + .radio__label { - margin-left: sizes.$x6; + margin-inline-start: sizes.$x6; } .radio__input { diff --git a/jsapp/js/components/common/tabs.module.scss b/jsapp/js/components/common/tabs.module.scss new file mode 100644 index 0000000000..846bd5b5be --- /dev/null +++ b/jsapp/js/components/common/tabs.module.scss @@ -0,0 +1,33 @@ +@use 'scss/colors'; +@use 'scss/sizes'; + +.root { + width: auto; + padding-top: sizes.$x20; + margin: 0 sizes.$x40; + display: flex; + flex-direction: row; + border-bottom: sizes.$x2 solid colors.$kobo-gray-85; +} + +.tab { + background: transparent; + cursor: pointer; + font-size: sizes.$x18; + color: colors.$kobo-gray-55; + font-weight: 600; + padding: sizes.$x14 sizes.$x30 sizes.$x10; + margin-bottom: -(sizes.$x2); + outline: none; + + &.active { + color: colors.$kobo-dark-blue; + background-color: colors.$kobo-bg-blue; + border-bottom: sizes.$x2 solid colors.$kobo-dark-blue; + border-radius: sizes.$x8 sizes.$x8 0 0; + } +} + +button { + border: none; +} diff --git a/jsapp/js/components/common/tabs.stories.tsx b/jsapp/js/components/common/tabs.stories.tsx new file mode 100644 index 0000000000..832491577f --- /dev/null +++ b/jsapp/js/components/common/tabs.stories.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import type {Story, Meta} from '@storybook/react'; +import type {TabsProps} from './tabs'; +import Tabs from './tabs'; + +export default { + title: 'Common/Tabs', + component: Tabs, + description: 'This is a component that provides a top tab navigation menu.', + argTypes: { + tabs: { + description: + 'Array of tab objects which contain strings defining the label and route', + }, + selectedTab: { + description: 'Defines the active tab for navigation and styling purposes', + control: 'text', + }, + onChange: { + description: 'Tab change callback ', + }, + }, +} as Meta; + +const tabsData = [ + {label: 'Tab 1', route: '/tab1'}, + {label: 'Tab 2', route: '/tab2'}, + {label: 'Tab 3', route: '/tab3'}, +]; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + tabs: tabsData, + selectedTab: '/tab1', +}; + +export const SelectedTab2 = Template.bind({}); +SelectedTab2.args = { + ...Default.args, + selectedTab: '/tab2', +}; diff --git a/jsapp/js/components/common/tabs.tsx b/jsapp/js/components/common/tabs.tsx new file mode 100644 index 0000000000..94f15712ee --- /dev/null +++ b/jsapp/js/components/common/tabs.tsx @@ -0,0 +1,69 @@ +import React, {useState} from 'react'; +import styles from './tabs.module.scss'; +import cx from 'classnames'; + +interface Tab { + label: string; + route: string; +} + +export interface TabsProps { + tabs: Tab[]; + selectedTab: string; + onChange: (route: string) => void; +} + +export default function Tabs({tabs, selectedTab, onChange}: TabsProps) { + const [activeTab, setActiveTab] = useState(selectedTab); + const [focus, setFocus] = useState(false); + + const handleTabKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + event.preventDefault(); + + const currentIndex = tabs.findIndex((tab) => tab.route === activeTab); + const offset = event.key === 'ArrowRight' ? 1 : tabs.length - 1; + const nextIndex = (currentIndex + offset) % tabs.length; + + setActiveTab(tabs[nextIndex].route); + onChange(tabs[nextIndex].route); + setFocus(true); + } + }; + + const handleTabBlur = () => { + setFocus(false); + }; + + const renderTab = (tab: Tab) => { + const isActiveTab = activeTab === tab.route; + + return { + key: tab.route, + onClick: () => { + setActiveTab(tab.route); + onChange(tab.route); + }, + onKeyDown: (event: React.KeyboardEvent) => + handleTabKeyDown(event), + onBlur: handleTabBlur, + className: cx(styles.tab, { + [styles.active]: isActiveTab, + [styles.focus]: focus && isActiveTab, + }), + 'aria-selected': isActiveTab, + tabIndex: isActiveTab ? 0 : -1, + role: 'tab', + }; + }; + + return ( +
    + {tabs.map((tab) => ( + + ))} +
    + ); +} diff --git a/jsapp/js/components/common/textBox.module.scss b/jsapp/js/components/common/textBox.module.scss index bf5119506d..42dd52ad2a 100644 --- a/jsapp/js/components/common/textBox.module.scss +++ b/jsapp/js/components/common/textBox.module.scss @@ -1,6 +1,6 @@ @use 'scss/sizes'; @use 'scss/mixins'; -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/_variables'; // Note: this needs to override a lot of styles defined in `_kobo.bem.ui.scss`, @@ -120,6 +120,11 @@ input[class].input.input { } } +textarea[class].input.input { + // Disallows resizing resizeable textarea to tiny size + min-height: 20px; +} + .startIcon, .endIcon { color: colors.$kobo-gray-40; diff --git a/jsapp/js/components/common/textBox.tsx b/jsapp/js/components/common/textBox.tsx index b61b5dd060..6a22e451b8 100644 --- a/jsapp/js/components/common/textBox.tsx +++ b/jsapp/js/components/common/textBox.tsx @@ -61,7 +61,9 @@ interface TextBoxProps { * uses this component. */ required?: boolean; - customClassNames?: string[]; + /** Additional class name */ + className?: string; + disableAutocomplete?: boolean; 'data-cy'?: string; /** Gives focus to the input immediately after rendering */ renderFocused?: boolean; @@ -122,9 +124,13 @@ export default function TextBox(props: TextBoxProps) { return true; } - const rootClassNames = props.customClassNames || []; + const rootClassNames = []; rootClassNames.push(styles.root); + if (props.className) { + rootClassNames.push(props.className); + } + let size: TextBoxSize = props.size || DefaultSize; switch (size) { case 'l': @@ -176,6 +182,9 @@ export default function TextBox(props: TextBoxProps) { // For `number` type we allow only positive integers step: props.type === 'number' ? 1 : undefined, min: props.type === 'number' ? 0 : undefined, + // All textboxes handles text direction of user content with browser + // built-in functionality + dir: 'auto', }; // For now we only support one size of TextBox, but when we're going to @@ -200,10 +209,11 @@ export default function TextBox(props: TextBoxProps) { )} - + {/* We use this to prevent browsers that ignore autocomplete='off' from attempting to fill the field */} + {props.disableAutocomplete && } {/* We use two different components based on the type of the TextBox */} {props.type === 'text-multiline' && ( ) => { onValueChange(evt.currentTarget.value); }} + autoComplete={props.disableAutocomplete ? 'off' : 'on'} {...inputProps} /> )} @@ -222,6 +233,7 @@ export default function TextBox(props: TextBoxProps) { aria-required={props.required} type={type} ref={inputReference} + autoComplete={props.disableAutocomplete ? 'off' : 'on'} // We use `onInput` instead of `onChange` here, because (for some // reason I wasn't able to grasp) `input[type="number"]` is not // calling onChange when non-number is typed, but regardless to that @@ -245,7 +257,7 @@ export default function TextBox(props: TextBoxProps) { )} {errors.length > 0 && ( @@ -253,7 +265,7 @@ export default function TextBox(props: TextBoxProps) { size={iconSize} name='alert' color='red' - classNames={[styles.errorIcon]} + className={styles.errorIcon} /> )}
    diff --git a/jsapp/js/components/common/toggleSwitch.scss b/jsapp/js/components/common/toggleSwitch.scss index c4d6e8bb66..f13934b127 100644 --- a/jsapp/js/components/common/toggleSwitch.scss +++ b/jsapp/js/components/common/toggleSwitch.scss @@ -1,5 +1,5 @@ -@use '~kobo-common/src/styles/colors'; -@use "scss/_variables"; +@use 'scss/colors'; +@use 'scss/_variables'; .toggle-switch { position: relative; @@ -10,6 +10,10 @@ cursor: pointer; } + &.toggle-switch--is-disabled .toggle-switch__wrapper { + cursor: default; + } + input { visibility: hidden; position: absolute; @@ -31,7 +35,7 @@ &::before { position: absolute; - content: ""; + content: ''; height: 16px; width: 16px; left: 2px; @@ -43,7 +47,8 @@ } .toggle-switch__slider + .toggle-switch__label { - margin-left: 6px; + // For RTL content + margin-inline-start: 6px; } input:checked + .toggle-switch__slider { diff --git a/jsapp/js/components/common/toggleSwitch.tsx b/jsapp/js/components/common/toggleSwitch.tsx index 65a2668a0c..f5f0de3ee9 100644 --- a/jsapp/js/components/common/toggleSwitch.tsx +++ b/jsapp/js/components/common/toggleSwitch.tsx @@ -22,7 +22,7 @@ class ToggleSwitch extends React.Component { render() { return ( - + { disabled={this.props.disabled} data-cy={this.props['data-cy']} /> - + - {this.props.label && + {this.props.label && ( {this.props.label} - } + )} ); diff --git a/jsapp/js/components/common/tooltip.scss b/jsapp/js/components/common/tooltip.scss index 12c2747f90..5596b1e49e 100644 --- a/jsapp/js/components/common/tooltip.scss +++ b/jsapp/js/components/common/tooltip.scss @@ -1,5 +1,6 @@ @use 'scss/z-indexes'; -@use '~kobo-common/src/styles/colors'; +@use 'scss/breakpoints'; +@use 'scss/colors'; /* Kobo Tooltips @@ -12,7 +13,7 @@ Additional class names: */ // Our own, css-only tooltips -@media screen and (min-width: 768px) { +@media screen and (min-width: breakpoints.$b768) { [data-tip] { position: relative; diff --git a/jsapp/js/components/common/tooltip.tsx b/jsapp/js/components/common/tooltip.tsx index 40eba80564..a64cf2c908 100644 --- a/jsapp/js/components/common/tooltip.tsx +++ b/jsapp/js/components/common/tooltip.tsx @@ -9,6 +9,7 @@ export interface TooltipProps { ariaLabel: string; /** Position of the tooltip (centered as default) */ alignment?: TooltipAlignment; + children?: React.ReactNode; } /** diff --git a/jsapp/js/components/dataAttachments/connect-projects.scss b/jsapp/js/components/dataAttachments/connect-projects.scss index c516e6f88e..770dade74a 100644 --- a/jsapp/js/components/dataAttachments/connect-projects.scss +++ b/jsapp/js/components/dataAttachments/connect-projects.scss @@ -1,4 +1,4 @@ -@use '~kobo-common/src/styles/colors'; +@use 'scss/colors'; @use 'scss/mixins'; @use 'scss/breakpoints'; @use 'scss/sizes'; @@ -21,10 +21,15 @@ display: block; margin-top: sizes.$x20; + *[class*='loadingSpinner'] { + margin-top: sizes.$x12; + } + .connect-projects__export-options { display: flex; justify-content: space-between; - padding-bottom: sizes.$x12; + flex-wrap: wrap; + gap: 12px; .toggle-switch { .toggle-switch__label { @@ -32,112 +37,103 @@ } } - // TODO: Create a BEM element that acts as column wrappers (and use - // modifiers for different columns) - // See: https://github.com/kobotoolbox/kpi/issues/3912 .checkbox { - width: 50%; + min-width: 200px; + width: 100%; } } + } +} - .connect-projects__export-multicheckbox { - display: flex; - justify-content: space-between; - position: relative; - padding-top: sizes.$x12; - border-top: sizes.$x1 solid; - border-color: colors.$kobo-gray-92; - - .connect-projects__export-hint { - width: 45%; - } +.connect-projects__export-multicheckbox { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + position: relative; + gap: 12px; + padding-top: sizes.$x12; + margin-top: sizes.$x12; + border-top: sizes.$x1 solid colors.$kobo-gray-92; +} - .multi-checkbox { - height: sizes.$x200; - width: 50%; - } +.connect-projects__import .connect-projects__import-form { + position: relative; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + align-content: center; + margin-top: sizes.$x10; + gap: 12px 24px; + + .kobo-select__wrapper { + flex: 3; + min-width: 280px; + + .kobo-select__placeholder { + color: colors.$kobo-gray-24; } } - .connect-projects__import { - .connect-projects__import-form { - position: relative; - display: flex; - flex-direction: row; - align-items: center; - align-content: center; - margin-top: sizes.$x10; - - .kobo-select__wrapper { - width: 50%; - margin-right: sizes.$x50; + .connect-projects-textbox { + flex: 2; + min-width: 200px; + } +} - .kobo-select__placeholder { - color: colors.$kobo-gray-24; - } - } +.connect-projects__import-list { + margin-top: sizes.$x20; - .connect-projects-textbox { - width: 35%; - margin-right: sizes.$x24; - } - } + label { + margin-top: sizes.$x20; + font-size: variables.$base-font-size; + font-weight: bold; + color: colors.$kobo-gray-40; } - .connect-projects__import-list { - margin-top: sizes.$x20; + .connect-projects__import-list-item, + .connect-projects__import-list-item--no-imports { + position: relative; + display: flex; + justify-content: space-between; + margin-top: sizes.$x8; + margin-bottom: sizes.$x10; + border-bottom: sizes.$x1 solid; + border-color: colors.$kobo-gray-92; + } - label { - margin-top: sizes.$x20; - font-size: variables.$base-font-size; - font-weight: bold; - color: colors.$kobo-gray-40; - } + .connect-projects__import-list-item--no-imports { + font-style: italic; + color: colors.$kobo-gray-65; + // Match vertcial height of a regular list item + padding: 11px 0 11px 11px; + } - .connect-projects__import-list-item, - .connect-projects__import-list-item--no-imports { - position: relative; - display: flex; - justify-content: space-between; - margin-top: sizes.$x8; - margin-bottom: sizes.$x10; - border-bottom: sizes.$x1 solid; - border-color: colors.$kobo-gray-92; - } + .connect-projects__import-list-item { + padding-bottom: sizes.$x10; - .connect-projects__import-list-item--no-imports { - font-style: italic; - color: colors.$kobo-gray-65; - // Match vertcial height of a regular list item - padding: 11px 0 11px 11px; + i.k-icon-check { + font-size: sizes.$x32; + margin-right: sizes.$x5; + color: colors.$kobo-blue; } - .connect-projects__import-list-item { - padding-bottom: sizes.$x10; + .connect-projects__import-labels { + position: absolute; + top: sizes.$x6; + left: sizes.$x32; + font-weight: 500; - i.k-icon-check { - font-size: sizes.$x32; - margin-right: sizes.$x5; - color: colors.$kobo-blue; - } - - .connect-projects__import-labels { - position: absolute; - top: sizes.$x6; - left: sizes.$x32; - font-weight: 500; - - .connect-projects__import-labels-source { - margin-left: sizes.$x24; - font-weight: 400; - color: colors.$kobo-gray-40; - } + .connect-projects__import-labels-source { + margin-left: sizes.$x24; + font-weight: 400; + color: colors.$kobo-gray-40; } + } - .connect-projects__import-options { - @include mixins.centerRowFlex; - gap: sizes.$x10; - } + .connect-projects__import-options { + @include mixins.centerRowFlex; + gap: sizes.$x10; } } } @@ -148,13 +144,18 @@ .bulk-options { margin-top: sizes.$x14; display: flex; + align-items: center; justify-content: space-between; + flex-wrap: wrap; + gap: 10px; .bulk-options__description { font-weight: bold; } .bulk-options__buttons { + white-space: nowrap; + span { margin: sizes.$x12; } @@ -171,61 +172,41 @@ height: sizes.$x200; } - .loading { + *[class*='loadingSpinner'] { margin-top: sizes.$x12; } .modal__footer { text-align: center; - button { + .data-attachment-modal-footer-button { padding-left: sizes.$x60; padding-right: sizes.$x60; } } } -// Compensate for when sidebar(s) messes up modal a bit - -// TODO: Clean this up via PR changes -// See: https://github.com/kobotoolbox/kpi/issues/3912 -@media - (min-width: breakpoints.$b1000) and (max-width: breakpoints.$b1140), - (min-width: breakpoints.$b768) and (max-width: breakpoints.$b860), - (max-width: breakpoints.$b700) { - .connect-projects__export-multicheckbox { - display: block !important; - - .multi-checkbox { - margin-top: sizes.$x12; - width: 100% !important; - overflow-x: scroll; - } - } - - .connect-projects__import-form { - display: block !important; - - .kobo-select__wrapper { - width: 100% !important; - margin-bottom: sizes.$x12; - } +.data-attachment-columns-multicheckbox { + overflow-x: auto; +} - .kobo-button { - display: block; - margin-top: sizes.$x12 auto 0; - width: 70%; - } +@include breakpoints.breakpoint(mediumAndUp) { + .connect-projects__export .connect-projects__export-option .checkbox { + max-width: 50%; } } -@media screen and (max-width: breakpoints.$b530) { - .connect-projects__export-options { - display: block !important; +@include breakpoints.breakpoint(narrowAndUp) { + .connect-projects__export-multicheckbox .connect-projects__export-hint { + flex: 1; + min-width: 280px; + max-width: 100%; + } - .checkbox { - margin-top: sizes.$x20; - width: 100% !important; - } + .connect-projects__export-multicheckbox .multi-checkbox { + flex: 1; + height: sizes.$x200; + min-width: 280px; + max-width: 100%; } } diff --git a/jsapp/js/components/dataAttachments/connectProjects.es6 b/jsapp/js/components/dataAttachments/connectProjects.es6 index 4b7ca25c5e..e8e571f2aa 100644 --- a/jsapp/js/components/dataAttachments/connectProjects.es6 +++ b/jsapp/js/components/dataAttachments/connectProjects.es6 @@ -9,7 +9,6 @@ import TextBox from 'js/components/common/textBox'; import Button from 'js/components/common/button'; import MultiCheckbox from 'js/components/common/multiCheckbox'; import {actions} from 'js/actions'; -import {stores} from 'js/stores'; import bem from 'js/bem'; import LoadingSpinner from 'js/components/common/loadingSpinner'; import envStore from 'js/envStore'; @@ -21,7 +20,7 @@ import { MODAL_TYPES, MAX_DISPLAYED_STRING_LENGTH, } from 'js/constants'; - +import pageState from 'js/pageState.store'; import './connect-projects.scss'; const DYNAMIC_DATA_ATTACHMENTS_SUPPORT_URL = 'dynamic_data_attachment.html'; @@ -333,7 +332,7 @@ class ConnectProjects extends React.Component { } showColumnFilterModal(asset, source, filename, fields, attachmentUrl) { - stores.pageState.showModal( + pageState.showModal( { type: MODAL_TYPES.DATA_ATTACHMENT_COLUMNS, asset: asset, @@ -405,7 +404,7 @@ class ConnectProjects extends React.Component { {this.state.isSharingAnyQuestions &&
    - + {t('Select any questions you want to share in the right side table')} {this.state.isLoading && @@ -444,19 +443,21 @@ class ConnectProjects extends React.Component { {this.renderSelect()} - - {t('Import')} - + label={t('Import')} + />
    {/* Display attached projects */} @@ -494,7 +495,7 @@ class ConnectProjects extends React.Component {