diff --git a/.eslintrc b/.eslintrc index c776e4527ac37..74805b2ec0291 100644 --- a/.eslintrc +++ b/.eslintrc @@ -37,92 +37,6 @@ "oppia/no-to-throw": "error" } }, - { - // We only run the e2e action checks on end-to-end test files, and - // we exclude files that we haven't fixed yet. - "files": [ - "core/tests/webdriverio/*.js", - "core/tests/webdriverio_utils/*.js", - "core/tests/webdriverio_desktop/*.js" - ], - "excludedFiles": [ - "ClassroomPage.js", - "DeleteAccountPage.js", - "ExplorationEditorHistoryTab.js", - "ExplorationEditorMainTab.js", - "ExplorationEditorPage.js", - "ExplorationEditorSettingsTab.js", - "ExplorationEditorStatsTab.js", - "ExplorationEditorTranslationTab.js", - "GetStartedPage.js", - "LearnerDashboardPage.js", - "LibraryPage.js", - "ProfilePage.js", - "SkillEditorPage.js", - "StoryEditorPage.js", - "SubscriptionDashboardPage.js", - "ThanksPage.js", - "TopicAndStoryViewerPage.js", - "TopicEditorPage.js", - "TopicsAndSkillsDashboardPage.js", - "action.js", - "forms.js", - "general.js", - "users.js", - "waitFor.js", - "workflow.js", - "additionalEditorFeatures.js", - "additionalPlayerFeatures.js", - "adminTabFeatures.js", - "classroomPage.js", - "collections.js", - "contributorDashboard.js", - "coreEditorAndPlayerFeatures.js", - "creatorDashboard.js", - "embedding.js", - "explorationFeedbackTab.js", - "explorationHistoryTab.js", - "explorationImprovementsTab.js", - "explorationStatisticsTab.js", - "explorationTranslationTab.js", - "extensions.js", - "fileUploadExtensions.js", - "learnerDashboard.js", - "navigation.js", - "playVoiceovers.js", - "preferences.js", - "profileFeatures.js", - "publicationAndLibrary.js", - "skillEditor.js", - "topicAndStoryEditor.js", - "topicAndStoryEditorFileUploadFeatures.js", - "topicAndStoryViewer.js", - "topicsAndSkillsDashboard.js", - "userJourneys.js", - "voiceoverUploadFeatures.js", - "wipeout.js", - "accessibility.js", - "learnerFlow.js", - "libraryFlow.js", - "profileMenuFlow.js", - "excludedForTesting.js" - ], - "rules": { - "oppia/e2e-action": "error" - } - }, - { - // We only run the e2e practices checks on webdriverio - // end-to-end test files. - "files": [ - "core/tests/webdriverio/*.js", - "core/tests/webdriverio_utils/*.js", - "core/tests/webdriverio_desktop/*.js" - ], - "rules": { - "oppia/e2e-practices": "error" - } - }, { // We exclude spec files from the no-testonly check "files": ["*spec.js", "*spec.ts"], diff --git a/.gcloudignore b/.gcloudignore index f2dd7f6c5b231..3d0040ebcac95 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -11,8 +11,8 @@ __pycache__/ # Markdown files. *.md -# Typescript files in core and extension they are not needed as -# we compile webpack which contains these files. +# Typescript files in core and extension are not needed as +# we use the Angular CLI build which compiles these files. core/**/*.ts extensions/**/*.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d5ef2cea39852..7f24728e8d94e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -73,7 +73,6 @@ /core/templates/utility/hashes.ts @oppia/lace-frontend-reviewers /core/templates/utility/string-utility*.ts @oppia/lace-frontend-reviewers /proxy.conf.json @oppia/lace-frontend-reviewers -/angular-template-style-url-replacer.webpack-loader.js @oppia/lace-frontend-reviewers /angular.json @oppia/lace-frontend-reviewers /src @oppia/lace-frontend-reviewers @@ -137,6 +136,7 @@ /core/domain/opportunity*.py @oppia/contributor-experience-team /core/domain/translation*.py @oppia/contributor-experience-team /core/templates/domain/opportunity/ @oppia/contributor-experience-team +/core/templates/domain/contributor_dashboard/ @oppia/contributor-experience-team /core/templates/pages/contributor-dashboard-page/ @oppia/contributor-experience-team /core/templates/pages/contributor-dashboard-admin-page/ @oppia/contributor-experience-team /core/templates/services/html-length.service.ts @oppia/contributor-experience-team @@ -741,14 +741,6 @@ /core/tests/ @oppia/dev-workflow-reviewers /assets/ @oppia/lace-frontend-reviewers /data/ @oppia/lace-backend-reviewers -/core/tests/webdriverio_utils/ @oppia/dev-workflow-reviewers -/core/tests/webdriverio_desktop/ @oppia/dev-workflow-reviewers -/core/tests/wdio.conf.js @oppia/dev-workflow-reviewers -/extensions/interactions/*/webdriverio.js @oppia/dev-workflow-reviewers -/extensions/interactions/webdriverio.js @oppia/dev-workflow-reviewers -/extensions/rich_text_components/*/webdriverio.js @oppia/dev-workflow-reviewers -/extensions/rich_text_components/webdriverio.js @oppia/dev-workflow-reviewers -/extensions/objects/webdriverio.js @oppia/dev-workflow-reviewers /scripts/backend_test_shards.json @oppia/dev-workflow-reviewers /.github/actions @oppia/dev-workflow-reviewers diff --git a/.github/ISSUE_TEMPLATE/3_e2e_acceptance_error_template.yml b/.github/ISSUE_TEMPLATE/3_e2e_acceptance_error_template.yml index 42806d07b344b..5e0d07384f2da 100644 --- a/.github/ISSUE_TEMPLATE/3_e2e_acceptance_error_template.yml +++ b/.github/ISSUE_TEMPLATE/3_e2e_acceptance_error_template.yml @@ -1,6 +1,6 @@ -name: E2E or Acceptance Test CI Failure Report +name: Acceptance Test CI Failure Report description: Create a report to help us improve -title: "[E2E/Acceptance CI Failure]: " +title: "[Acceptance CI Failure]: " labels: [triage needed, bug, CI breakage] body: - type: markdown @@ -33,7 +33,6 @@ body: label: CI Test Type description: In which CI test did this failure occur? options: - - E2E - Acceptance validations: required: true diff --git a/.github/ISSUE_TEMPLATE/4_ci_error_template.yml b/.github/ISSUE_TEMPLATE/4_ci_error_template.yml index 306d39b8a1c41..6d6b21526d2d4 100644 --- a/.github/ISSUE_TEMPLATE/4_ci_error_template.yml +++ b/.github/ISSUE_TEMPLATE/4_ci_error_template.yml @@ -19,15 +19,14 @@ body: attributes: label: CI Test Type description: > - In which CI test did this failure occur? For E2E or Acceptance test failures, + In which CI test did this failure occur? For acceptance test failures, please use our dedicated template instead by navigating to our [issues page](https://github.com/oppia/oppia/issues), clicking "New issue", - and selecting the template for "E2E or Acceptance Test CI Failure Report". + and selecting the template for "Acceptance Test CI Failure Report". options: - Frontend - Backend - - Lighthouse CI performance - - Lighthouse CI accessibility + - Lighthouse CI - Other validations: required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6ddf32be34dee..d6ca5f361a3a8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -96,5 +96,5 @@ language set to Arabic as well (we use Arabic as it is a language written from r - Never force push! If you do, your PR will be closed. - To reply to reviewers, follow these instructions: https://github.com/oppia/oppia/wiki/Rules-for-making-PRs#step-5-address-review-comments-until-all-reviewers-approve -- Some e2e tests are flaky, and can fail for reasons unrelated to your PR. We are working on fixing this, but in the meantime, if you need to restart the tests, please check the ["If your build fails" wiki page](https://github.com/oppia/oppia/wiki/If-CI-checks-fail-on-your-PR). +- Some acceptance tests are flaky, and can fail for reasons unrelated to your PR. We are working on fixing this, but in the meantime, if you need to restart the tests, please check the ["If your build fails" wiki page](https://github.com/oppia/oppia/wiki/If-CI-checks-fail-on-your-PR). - See the [Code Owner's wiki page](https://github.com/oppia/oppia/wiki/Oppia's-code-owners-and-checks-to-be-carried-out-by-developers) for what code owners will expect. diff --git a/.github/actions/generate-build-files/action.yml b/.github/actions/generate-build-files/action.yml index 9ca2c27939f1d..301c5ca480552 100644 --- a/.github/actions/generate-build-files/action.yml +++ b/.github/actions/generate-build-files/action.yml @@ -15,16 +15,15 @@ runs: key: ${{ runner.os }}-build-files-${{ inputs.prod-env != 'false' && 'prod' || 'dev' }}-${{ github.sha }} path: | /home/runner/work/oppia/oppia/build - /home/runner/work/oppia/oppia/webpack_bundles /home/runner/work/oppia/oppia/app.yaml /home/runner/work/oppia/oppia/assets/hashes.json /home/runner/work/oppia/oppia/dist /home/runner/work/oppia/oppia/third_party/generated - - name: Build Webpack (prod) + - name: Build (prod) if: steps.restore_build_files_cache.outputs.cache-hit != 'true' && inputs.prod-env != 'false' run: python -m scripts.build --prod_env shell: bash - - name: Build Webpack (dev) + - name: Build (dev) if: steps.restore_build_files_cache.outputs.cache-hit != 'true' && inputs.prod-env == 'false' run: python -c "from scripts import build; build.build_js_files(dev_mode=True)" shell: bash diff --git a/.github/actions/run-a-specific-acceptance-test/action.yml b/.github/actions/run-a-specific-acceptance-test/action.yml index 28e3adaf11260..6db049e2e6dbc 100644 --- a/.github/actions/run-a-specific-acceptance-test/action.yml +++ b/.github/actions/run-a-specific-acceptance-test/action.yml @@ -136,7 +136,7 @@ runs: # which requires all previous jobs/steps to have succeeded. This is not what we want. # Reference: https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#status-check-functions # Quote: "A default status check of success() is applied unless you *include* one of these functions." - if: ${{ !cancelled() && steps.run-acceptance-test.outcome == 'failure' && inputs.modified-suite-name-for-uploads != '' }} + if: ${{ !cancelled() && inputs.modified-suite-name-for-uploads != '' }} uses: actions/upload-artifact@v4 with: name: video-recordings_${{ inputs.modified-suite-name-for-uploads }} diff --git a/.github/workflows/full_stack_tests.yml b/.github/workflows/full_stack_tests.yml index 95f21ee9c748c..450cf395a23fe 100644 --- a/.github/workflows/full_stack_tests.yml +++ b/.github/workflows/full_stack_tests.yml @@ -36,8 +36,8 @@ jobs: run: | SHA=$(git ls-remote https://github.com/oppia/oppia.git refs/heads/develop | awk '{print $1}') echo "sha=$SHA" >> "$GITHUB_OUTPUT" - e2e_and_acceptance_coverage: - name: Verify all e2e/acceptance tests are included + acceptance_coverage: + name: Verify all acceptance tests are included needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. @@ -52,14 +52,14 @@ jobs: uses: ./.github/actions/merge-develop-and-set-up-dependencies with: merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - - name: Check that all e2e and acceptance test files are captured in wdio.conf.js and core/tests/ci-test-suite-configs + - name: Check that all acceptance test files are captured in core/tests/ci-test-suite-configs run: python -m scripts.check_tests_are_captured_in_ci - name: Report failure if failed on oppia/oppia develop branch if: ${{ failure() && github.event_name == 'push' && github.repository == 'oppia/oppia' && github.ref == 'refs/heads/develop'}} uses: ./.github/actions/send-webhook-notification with: - message: "The e2e/acceptance coverage test failed on the upstream develop branch." + message: "The acceptance coverage test failed on the upstream develop branch." webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} check_test_suites_to_run: name: Compute which tests to run @@ -99,8 +99,7 @@ jobs: name: Build the app, and store build files as an artifact needs: [check_test_suites_to_run, setup_merge_sha] runs-on: ubuntu-22.04 - if: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).e2e.count > 0 || - fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse_performance.count > 0 || + if: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse.count > 0 || fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).acceptance.count > 0 }} steps: - name: Checkout repository so that local actions can be used @@ -111,106 +110,6 @@ jobs: merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Generate build files uses: ./.github/actions/generate-build-files - e2e_test: - needs: [check_test_suites_to_run, build, setup_merge_sha] - runs-on: ubuntu-22.04 - if: ${{ - fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).e2e.count > 0 - && github.event_name != 'merge_group' - }} - strategy: - fail-fast: ${{ github.event_name == 'merge_group' }} - matrix: - suite: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).e2e.suites }} - name: E2E (${{ matrix.suite.name }}) - steps: - - name: Check for broken suites - id: check_skip - # TODO(#22448): Remove once all suites are fixed. - run: | - broken_suites=("contributorDashboard" "contributorAdminDashboard" "explorationTranslationTab" "fileUploadFeatures") - for suite in "${broken_suites[@]}"; do - if [[ "${{ matrix.suite.name }}" == "$suite" ]]; then - echo "Suite '${{ matrix.suite.name }}' is marked to be skipped." - echo "SKIP_SUITE=true" >> $GITHUB_OUTPUT - break - fi - done - - name: Checkout repository so that local actions can be used - uses: actions/checkout@v4 - - name: Merge develop and set up dependencies - uses: ./.github/actions/merge-develop-and-set-up-dependencies - with: - merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - - name: Generate build files - uses: ./.github/actions/generate-build-files - - name: Install Chrome - uses: ./.github/actions/install-chrome - - name: Install ffmpeg for wdio videos - run: | - sudo apt-get update - sudo apt install ffmpeg - - name: Generate modified suite name for artifacts - id: generate_suite_name_for_artifacts - # Replace slashes in the filename with hyphens. - run: | - SUITE_NAME=${{ matrix.suite.name }} - echo "MODIFIED_SUITE_NAME=${SUITE_NAME//\//_}" >> $GITHUB_OUTPUT - - name: Run E2E Test ${{ matrix.suite.name }} - # TODO(#22448): Remove once all suites are fixed. - if: ${{ steps.check_skip.outputs.SKIP_SUITE != 'true' }} - run: > - set -o pipefail; - VIDEO_RECORDING_IS_ENABLED=1 - xvfb-run -a --server-args="-screen 0, 1285x1000x24" - python -m scripts.run_e2e_tests --skip_install - --skip_build --suite=${{ matrix.suite.name }} --prod_env --server_log_level=info - | tee e2e_test_${{ steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME }}.log - - name: Upload E2E test logs - if: ${{ failure() }} - uses: actions/upload-artifact@v4 - with: - name: e2e-test-logs-${{ steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME }} - path: e2e_test_${{ steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME }}.log - - name: Run E2E Test ${{ matrix.suite.name }} (Attempt 2 with screen recording) - if: ${{ failure() }} - run: > - set -o pipefail; - VIDEO_RECORDING_IS_ENABLED=1 - xvfb-run -a --server-args="-screen 0, 1285x1000x24" - python -m scripts.run_e2e_tests --skip_install - --skip_build --suite=${{ matrix.suite.name }} --prod_env --server_log_level=info - | tee e2e_test_retry_${{ steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME }}.log - - name: Upload E2E retry test logs - if: ${{ failure() }} - uses: actions/upload-artifact@v4 - with: - name: e2e-test-retry-logs-${{ steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME }} - path: e2e_test_retry_${{ steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME }}.log - - name: Uploading webdriverio-video as Artifacts - if: ${{ failure() }} - uses: actions/upload-artifact@v4 - with: - name: webdriverio-video-${{steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME}} - path: /home/runner/work/oppia/webdriverio-video - - name: Uploading webdriverio screenshots as Artifacts - if: ${{ failure() }} - uses: actions/upload-artifact@v4 - with: - name: webdriverio-screenshots-${{steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME}} - path: /home/runner/work/oppia/webdriverio-screenshots - - name: Uploading webpack bundles as an artifact - if: ${{ failure() }} - uses: actions/upload-artifact@v4 - with: - name: webpack-bundles-${{steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME}} - path: /home/runner/work/oppia/oppia/build - - name: Report failure if failed on oppia/oppia develop branch - if: ${{ failure() && github.event_name == 'push' && github.repository == 'oppia/oppia' && github.ref == 'refs/heads/develop'}} - uses: ./.github/actions/send-webhook-notification - with: - message: "An E2E test failed on the upstream develop branch." - webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} acceptance_test: needs: [check_test_suites_to_run, build, setup_merge_sha] runs-on: ubuntu-22.04 @@ -312,17 +211,17 @@ jobs: # Upload other essential files. - name: Upload generated test to angular module mapping - if: ${{ failure() && (steps.desktop-test-attempt-1.outcome == 'failure' || steps.mobile-test-attempt-1.outcome == 'failure') }} + if: ${{ failure() && (steps.desktop-test.outcome == 'failure' || steps.mobile-test.outcome == 'failure') }} uses: actions/upload-artifact@v4 with: name: generated-test-to-angular-module-mapping-${{steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME}} path: /home/runner/work/oppia/oppia/core/tests/test-modules-mappings/acceptance/${{ steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME }}.txt - - name: Upload webpack bundles - if: ${{ failure() && (steps.desktop-test-attempt-1.outcome == 'failure' || steps.mobile-test-attempt-1.outcome == 'failure') }} + - name: Upload build artifacts + if: ${{ failure() && (steps.desktop-test.outcome == 'failure' || steps.mobile-test.outcome == 'failure') }} uses: actions/upload-artifact@v4 with: - name: webpack-bundles-${{steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME}} + name: build-${{steps.generate_suite_name_for_artifacts.outputs.MODIFIED_SUITE_NAME}} path: /home/runner/work/oppia/oppia/build - name: Report failure if failed on oppia/oppia develop branch @@ -337,52 +236,18 @@ jobs: message: "An acceptance test failed on the upstream develop branch." webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} - lighthouse_accessibility_test: - needs: [check_test_suites_to_run] - if: ${{ - fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse_accessibility.count > 0 - && github.event_name != 'merge_group' - }} - runs-on: ubuntu-22.04 - strategy: - fail-fast: ${{ github.event_name == 'merge_group' }} - matrix: - suite: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse_accessibility.suites }} - name: Lighthouse a11y (shard ${{ matrix.suite.name }}) - steps: - - name: Checkout repository so that local actions can be used - uses: actions/checkout@v4 - - name: Merge develop and set up dependencies - uses: ./.github/actions/merge-develop-and-set-up-dependencies - with: - merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - - name: Install Chrome - if: startsWith(github.head_ref, 'update-changelog-for-release') == false - uses: ./.github/actions/install-chrome - - name: Run Lighthouse accessibility checks shard ${{ matrix.suite.name }} - if: startsWith(github.head_ref, 'update-changelog-for-release') == false - run: | - python -m scripts.run_lighthouse_tests --mode accessibility --pages ${{ join(matrix.suite.pages_to_run, ',') }} - shell: bash - timeout-minutes: 45 - - name: Report failure if failed on oppia/oppia develop branch - if: ${{ failure() && github.event_name == 'push' && github.repository == 'oppia/oppia' && github.ref == 'refs/heads/develop'}} - uses: ./.github/actions/send-webhook-notification - with: - message: "A Lighthouse test failed on the upstream develop branch." - webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} - lighthouse_performance_test: + lighthouse_test: needs: [check_test_suites_to_run, build, setup_merge_sha] runs-on: ubuntu-22.04 if: ${{ - fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse_performance.count > 0 + fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse.count > 0 && github.event_name != 'merge_group' }} strategy: fail-fast: ${{ github.event_name == 'merge_group' }} matrix: - suite: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse_performance.suites }} - name: Lighthouse perf (shard ${{ matrix.suite.name }}) + suite: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse.suites }} + name: Lighthouse tests (shard ${{ matrix.suite.name }}) steps: - name: Checkout repository so that local actions can be used uses: actions/checkout@v4 @@ -401,9 +266,9 @@ jobs: run: | SUITE_NAME=${{ matrix.suite.name }} echo "MODIFIED_SUITE_NAME=${SUITE_NAME//\//_}" >> $GITHUB_OUTPUT - - name: Run Lighthouse performance checks (shard ${{ matrix.suite.name }}) + - name: Run Lighthouse checks (shard ${{ matrix.suite.name }}) if: startsWith(github.head_ref, 'update-changelog-for-release') == false - run: python -m scripts.run_lighthouse_tests --mode performance --skip_build --record_screen --pages ${{ join(matrix.suite.pages_to_run, ',') }} + run: python -m scripts.run_lighthouse_tests --skip_build --record_screen --pages ${{ join(matrix.suite.pages_to_run, ',') }} timeout-minutes: 45 - name: Uploading puppeteer video as artifact if: ${{ failure() }} @@ -415,19 +280,17 @@ jobs: if: ${{ failure() && github.event_name == 'push' && github.repository == 'oppia/oppia' && github.ref == 'refs/heads/develop'}} uses: ./.github/actions/send-webhook-notification with: - message: "A Lighthouse performance test failed on the upstream develop branch." + message: "A Lighthouse test failed on the upstream develop branch." webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} check_workflow_statuses: - # This job is needed because we cannot make each e2e/acceptance/lighthouse + # This job is needed because we cannot make each acceptance/lighthouse # task a "required" check via GitHub, since different tasks may execute # depending on which files are modified in the PR. name: Check that all necessary tests pass needs: - check_test_suites_to_run - - e2e_test - acceptance_test - - lighthouse_accessibility_test - - lighthouse_performance_test + - lighthouse_test - setup_merge_sha if: always() runs-on: ubuntu-22.04 @@ -453,7 +316,7 @@ jobs: }} run: | echo "" - echo "NOTE TO DEVELOPERS: This CI check is failing because another e2e/acceptance/lighthouse full-stack test workflow has failed. Please see that other workflow for the relevant logs to investigate. When all full-stack workflows pass, this CI check will automatically pass." + echo "NOTE TO DEVELOPERS: This CI check is failing because another acceptance/lighthouse full-stack test workflow has failed. Please see that other workflow for the relevant logs to investigate. When all full-stack workflows pass, this CI check will automatically pass." echo "" echo "If all other full-stack workflows have passed and this is the only failure, please file an issue on our issue tracker and link to this log. Thanks!" echo "" diff --git a/.gitignore b/.gitignore index 749a3f1511da0..80c0651970b50 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ assets/hashes.json local_compiled_js/* local_compiled_js_for_test/* core/templates/prod/* -webpack_bundles/* core/tests/.browserstack.env core/tests/puppeteer-acceptance-tests/build core/tests/puppeteer-acceptance-tests/specs/*/prod-desktop-screenshots/diff-snapshots diff --git a/.lighthouserc-accessibility.js b/.lighthouserc-accessibility.js deleted file mode 100644 index c1d7cff20fe01..0000000000000 --- a/.lighthouserc-accessibility.js +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright 2020 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview Configuration for accessibility. - */ - -const baseConfig = require('./.lighthouserc-base.js'); - -module.exports = { - ci: { - collect: { - numberOfRuns: baseConfig['numberOfRuns'], - puppeteerScript: baseConfig['puppeteerScript'], - url: baseConfig['urls'], - }, - assert: { - assertMatrix: [ - { - matchingUrlPattern: '^http://localhost:8181/$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/about$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/admin$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/blog-dashboard$', - assertions: { - 'categories:accessibility': ['error', {minScore: 0.98}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/community-library$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/contact$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/contributor-dashboard$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/creator-dashboard$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/delete-account$', - assertions: { - 'categories:accessibility': ['error', {minScore: 0.98}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/donate$', - assertions: { - 'categories:accessibility': ['error', {minScore: 0.98}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/get-started$', - assertions: { - 'categories:accessibility': ['error', {minScore: 0.98}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/learner-dashboard$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/moderator$', - assertions: { - 'categories:accessibility': ['error', {minScore: 0.98}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/preferences$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/privacy-policy$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/profile/username1$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/signup?return_url=%2F$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/teach$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: - '^http://localhost:8181/topics-and-skills-dashboard$', - assertions: { - 'categories:accessibility': ['error', {minScore: 0.9}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/terms$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/thanks$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/volunteer$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: - '^http://localhost:8181/learn/staging/dummy-topic-one/story', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: - '^http://localhost:8181/learn/staging/dummy-topic-one/story/help-jamie-win-arcade', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/learn/math', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/create/.*$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/explore/.*$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/topic_editor/.*$', - assertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/skill_editor/.*$', - assertions: { - 'categories:accessibility': ['error', {minScore: 0.91}], - }, - }, - { - matchingUrlPattern: '^http://localhost:8181/story_editor/.*$', - assertions: { - 'categories:accessibility': ['error', {minScore: 0.84}], - }, - }, - ], - }, - upload: { - target: 'temporary-public-storage', - }, - }, -}; diff --git a/.lighthouserc-base.js b/.lighthouserc-base.js index 3694dc0d68f03..32e492b0c79df 100644 --- a/.lighthouserc-base.js +++ b/.lighthouserc-base.js @@ -13,7 +13,7 @@ // limitations under the License. /** - * @fileoverview Configuration for lighthouse-ci. + * @fileoverview Shared configuration and helpers for lighthouse-ci. */ const dotenv = require('dotenv'); @@ -24,46 +24,166 @@ const LIGHTHOUSE_URLS_TO_RUN = process.env.LIGHTHOUSE_URLS_TO_RUN ? process.env.LIGHTHOUSE_URLS_TO_RUN.split(',') : ALL_LIGHTHOUSE_URLS; -module.exports = { - numberOfRuns: 3, - puppeteerScript: 'puppeteer-login-script.js', - urls: LIGHTHOUSE_URLS_TO_RUN, - basePerformanceAssertMatrix: { +const basePerformanceAssertions = { + 'errors-in-console': ['error', {minScore: 1}], + 'modern-image-formats': ['error', {maxLength: 0, strategy: 'pessimistic'}], + 'uses-passive-event-listeners': ['error', {minScore: 1}], + deprecations: ['error', {minScore: 1}], + redirects: ['error', {minScore: 1}], + 'uses-responsive-images': ['error', {minScore: 0.8}], + charset: ['warn', {minScore: 1}], + viewport: ['warn', {minScore: 1}], + 'font-size': ['warn', {minScore: 1}], + 'image-size-responsive': ['warn', {minScore: 1}], + 'third-party-cookies': ['warn', {minScore: 1}], + 'inspector-issues': ['warn', {minScore: 1}], + 'redirects-http': ['warn', {minScore: 1}], +}; + +/** + * Builds the catch-all assert matrix entry with performance metric thresholds. + * + * @param {Object} perfThresholds - Performance metric maxNumericValue thresholds + * in milliseconds (CLS is unitless). + * @param {number} perfThresholds.fcp - First Contentful Paint. + * @param {number} perfThresholds.speedIndex - Speed Index. + * @param {number} perfThresholds.lcp - Largest Contentful Paint. + * @param {number} perfThresholds.tbt - Total Blocking Time. + * @param {number} perfThresholds.cls - Cumulative Layout Shift. + * @returns {Object} The catch-all assert matrix entry. + */ +function buildPerformanceCatchAll(perfThresholds) { + return { matchingUrlPattern: '.*', assertions: { - // Performance category. - 'first-contentful-paint': ['warn', {maxNumericValue: 1230000}], - 'first-meaningful-paint': ['warn', {maxNumericValue: 1280000}], - 'speed-index': ['warn', {maxNumericValue: 1230000}], - interactive: ['warn', {maxNumericValue: 1540000}], - 'max-potential-fid': ['warn', {maxNumericValue: 130000}], + // Performance metrics — error-level safety net. Per-page thresholds + // override these at tighter values; these catch any unlisted page. + 'first-contentful-paint': [ + 'error', + {maxNumericValue: perfThresholds.fcp}, + ], + 'speed-index': ['error', {maxNumericValue: perfThresholds.speedIndex}], + 'largest-contentful-paint': [ + 'error', + {maxNumericValue: perfThresholds.lcp}, + ], + 'total-blocking-time': ['error', {maxNumericValue: perfThresholds.tbt}], + 'cumulative-layout-shift': [ + 'error', + {maxNumericValue: perfThresholds.cls}, + ], 'uses-optimized-images': ['error', {minScore: 1}], 'uses-rel-preconnect': ['error', {minScore: 0.5}], 'efficient-animated-content': ['error', {minScore: 1}], - 'offscreen-images': ['error', {minScore: 0.45}], - 'time-to-first-byte': ['off', {}], + 'server-response-time': ['off', {}], // Best practices category. 'no-document-write': ['error', {minScore: 1}], 'geolocation-on-start': ['error', {minScore: 1}], doctype: ['error', {minScore: 1}], - 'no-vulnerable-libraries': ['off', {minScore: 1}], 'notification-on-start': ['error', {minScore: 1}], - 'password-inputs-can-be-pasted-into': ['error', {minScore: 1}], + 'paste-preventing-inputs': ['error', {minScore: 1}], 'image-aspect-ratio': ['error', {minScore: 1}], 'is-on-https': ['off', {}], 'uses-http2': ['off', {}], }, + }; +} + +/** + * Builds the assertion object for a single page, merging base performance + * assertions with page-specific overrides and per-page performance thresholds. + * + * @param {Object} overrides - Page-specific audit overrides. + * @param {number} accessibilityMinScore - Minimum accessibility score. + * @param {Object|null} pagePerfThresholds - Per-page performance metric + * thresholds (fcp, speedIndex, lcp, tbt, cls). When provided, these override + * the global catch-all thresholds for this page at error level. + * @returns {Object} The merged assertion object. + */ +function buildPageAssertions( + overrides = {}, + accessibilityMinScore = 1, + pagePerfThresholds = null +) { + const perfAssertions = pagePerfThresholds + ? { + 'first-contentful-paint': [ + 'error', + {maxNumericValue: pagePerfThresholds.fcp}, + ], + 'speed-index': [ + 'error', + {maxNumericValue: pagePerfThresholds.speedIndex}, + ], + 'largest-contentful-paint': [ + 'error', + {maxNumericValue: pagePerfThresholds.lcp}, + ], + 'total-blocking-time': [ + 'error', + {maxNumericValue: pagePerfThresholds.tbt}, + ], + 'cumulative-layout-shift': [ + 'error', + {maxNumericValue: pagePerfThresholds.cls}, + ], + } + : {}; + return { + ...basePerformanceAssertions, + ...perfAssertions, + 'categories:accessibility': ['error', {minScore: accessibilityMinScore}], + 'categories:seo': ['warn', {minScore: 1}], + ...overrides, + }; +} + +/** + * Builds the full assert matrix by prepending the performance catch-all + * entry and then appending one entry per page. + * + * @param {Array} pageConfigs - Array of objects, each with: + * - matchingUrlPattern {string}: Regex pattern for the URL. + * - overrides {Object}: (optional) Page-specific audit overrides. + * - accessibilityMinScore {number}: (optional) Min accessibility score. + * - pagePerfThresholds {Object}: (optional) Per-page performance thresholds + * with keys fcp, speedIndex, lcp, tbt, cls. Overrides the global catch-all + * at error level for this specific page. + * @param {Object} perfThresholds - Performance metric thresholds for the + * catch-all entry (fcp, speedIndex, lcp, tbt, cls). + * @returns {Array} The full LHCI assert matrix. + */ +function buildAssertMatrix(pageConfigs, perfThresholds) { + return [ + buildPerformanceCatchAll(perfThresholds), + ...pageConfigs.map( + ({ + matchingUrlPattern, + overrides, + accessibilityMinScore, + pagePerfThresholds, + }) => ({ + matchingUrlPattern, + assertions: buildPageAssertions( + overrides, + accessibilityMinScore, + pagePerfThresholds + ), + }) + ), + ]; +} + +module.exports = { + buildPageAssertions, + buildAssertMatrix, + numberOfRuns: 3, + puppeteerScript: 'puppeteer-login-script.js', + // CI-stability flags for the Chrome instance managed by the puppeteerScript. + // These match the flags already used by Karma in core/tests/karma.conf.ts. + puppeteerLaunchOptions: { + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], }, - basePerformanceAssertions: { - 'errors-in-console': ['error', {minScore: 1}], - 'modern-image-formats': ['error', {maxLength: 0, strategy: 'pessimistic'}], - 'uses-passive-event-listeners': ['error', {minScore: 1}], - 'uses-rel-preload': ['error', {minScore: 1}], - deprecations: ['error', {minScore: 1}], - redirects: ['error', {minScore: 1}], - 'uses-responsive-images': ['error', {minScore: 0.8}], - }, - baseAccessibilityAssertions: { - 'categories:accessibility': ['error', {minScore: 1}], - }, + urls: LIGHTHOUSE_URLS_TO_RUN, + basePerformanceAssertions, }; diff --git a/.lighthouserc-desktop.js b/.lighthouserc-desktop.js new file mode 100644 index 0000000000000..5ed9923245a7c --- /dev/null +++ b/.lighthouserc-desktop.js @@ -0,0 +1,534 @@ +// Copyright 2020 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Configuration for lighthouse-ci desktop runs. + * + * This config runs the same pages as .lighthouserc.js but with desktop + * emulation and its own assertion thresholds. The assertion matrix is defined + * independently so that desktop thresholds can diverge from mobile over time. + */ + +const baseConfig = require('./.lighthouserc-base.js'); + +// Desktop emulation metrics, matching the desktop preset in Lighthouse +// (see node_modules/lighthouse/core/config/constants.js). +const DESKTOP_SCREEN_EMULATION = { + mobile: false, + width: 1350, + height: 940, + deviceScaleFactor: 1, + disabled: false, +}; + +// Throttling for a dense 4G desktop connection, matching the desktopDense4G +// constants in Lighthouse's lantern simulation. The request latency and +// throughput values are set to 0 (meaning unset) so that Lantern derives them +// from rttMs and throughputKbps; otherwise the mobile defaults would persist +// through the settings merge. +const DESKTOP_THROTTLING = { + rttMs: 40, + throughputKbps: 10240, + cpuSlowdownMultiplier: 1, + requestLatencyMs: 0, + downloadThroughputKbps: 0, + uploadThroughputKbps: 0, +}; + +module.exports = { + ci: { + collect: { + numberOfRuns: baseConfig['numberOfRuns'], + puppeteerScript: baseConfig['puppeteerScript'], + url: baseConfig['urls'], + settings: { + formFactor: 'desktop', + screenEmulation: DESKTOP_SCREEN_EMULATION, + throttling: DESKTOP_THROTTLING, + // Set to true so that Lighthouse uses the user agent associated with + // the desktop form factor. + emulatedUserAgent: true, + onlyCategories: [ + 'performance', + 'accessibility', + 'best-practices', + 'seo', + ], + }, + }, + assert: { + assertMatrix: baseConfig.buildAssertMatrix( + [ + { + matchingUrlPattern: 'http://[^/]+/$', + pagePerfThresholds: { + fcp: 3045, + speedIndex: 4680, + lcp: 10185, + tbt: 300, + cls: 0.15, + }, + }, + { + matchingUrlPattern: 'http://[^/]+/about$', + pagePerfThresholds: { + fcp: 4380, + speedIndex: 5670, + lcp: 12750, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.88, + }, + { + matchingUrlPattern: 'http://[^/]+/admin$', + pagePerfThresholds: { + fcp: 2820, + speedIndex: 2820, + lcp: 8415, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.93, + }, + { + matchingUrlPattern: '^http://localhost:8181/blog-dashboard$', + pagePerfThresholds: { + fcp: 2835, + speedIndex: 3135, + lcp: 11100, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.92, + }, + { + matchingUrlPattern: 'http://[^/]+/community-library$', + pagePerfThresholds: { + fcp: 2835, + speedIndex: 4665, + lcp: 9240, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: 'http://[^/]+/contact$', + pagePerfThresholds: { + fcp: 5550, + speedIndex: 4755, + lcp: 9015, + tbt: 300, + cls: 0.44, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/contributor-dashboard$', + pagePerfThresholds: { + fcp: 2955, + speedIndex: 5385, + lcp: 10785, + tbt: 300, + cls: 0.2, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/creator-dashboard$', + pagePerfThresholds: { + fcp: 2775, + speedIndex: 4170, + lcp: 11400, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.88, + }, + { + matchingUrlPattern: 'http://[^/]+/creator-guidelines$', + pagePerfThresholds: { + fcp: 4230, + speedIndex: 5700, + lcp: 8940, + tbt: 300, + cls: 0.51, + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: 'http://[^/]+/delete-account$', + pagePerfThresholds: { + fcp: 4575, + speedIndex: 4575, + lcp: 10950, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/donate$', + pagePerfThresholds: { + fcp: 4395, + speedIndex: 5670, + lcp: 9315, + tbt: 300, + cls: 0.51, + }, + overrides: { + 'errors-in-console': ['error', {minScore: 0}], + // The YouTube embed on donate page loads images in jpg format, + // thus we need to allow one image. + 'modern-image-formats': [ + 'error', + {maxLength: 1, strategy: 'pessimistic'}, + ], + // The YouTube embed on donate page uses passive listeners. + 'uses-passive-event-listeners': ['error', {minScore: 0}], + deprecations: ['error', {minScore: 0}], + redirects: ['error', {minScore: 1}], + 'uses-responsive-images': ['error', {minScore: 0.5}], + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: 'http://[^/]+/get-started$', + pagePerfThresholds: { + fcp: 4395, + speedIndex: 4395, + lcp: 10500, + tbt: 300, + cls: 0.57, + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: 'http://[^/]+/learner-dashboard$', + pagePerfThresholds: { + fcp: 4200, + speedIndex: 4500, + lcp: 13500, + tbt: 800, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + overrides: { + 'errors-in-console': ['error', {minScore: 1}], + 'modern-image-formats': [ + 'error', + {maxLength: 0, strategy: 'pessimistic'}, + ], + // We need to use passive event listeners on this page so that + // the page works correctly. + 'uses-passive-event-listeners': ['error', {minScore: 0}], + // Sign up redirects logged-in user to learner dashboard page. + // Learner dashboard Page cannot be preloaded. + deprecations: ['error', {minScore: 1}], + redirects: ['error', {minScore: 0}], + 'uses-responsive-images': ['error', {minScore: 0.8}], + // All images are offscreen on the learner dashboard. + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/license$', + pagePerfThresholds: { + fcp: 4410, + speedIndex: 4680, + lcp: 9030, + tbt: 300, + cls: 0.29, + }, + accessibilityMinScore: 0.91, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/moderator$', + pagePerfThresholds: { + fcp: 2790, + speedIndex: 4410, + lcp: 9135, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.92, + }, + { + matchingUrlPattern: 'http://[^/]+/preferences$', + pagePerfThresholds: { + fcp: 2955, + speedIndex: 4575, + lcp: 9585, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.84, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/privacy-policy$', + pagePerfThresholds: { + fcp: 4440, + speedIndex: 4440, + lcp: 8955, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/profile/username1$', + pagePerfThresholds: { + fcp: 2925, + speedIndex: 4515, + lcp: 9405, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/signup\\?return_url=%2F$', + pagePerfThresholds: { + fcp: 4650, + speedIndex: 5700, + lcp: 10800, + tbt: 300, + cls: 0.6, + }, + }, + { + matchingUrlPattern: 'http://[^/]+/teach$', + pagePerfThresholds: { + fcp: 2760, + speedIndex: 6150, + lcp: 9390, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + overrides: { + 'uses-responsive-images': ['error', {minScore: 0.5}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/terms$', + pagePerfThresholds: { + fcp: 4245, + speedIndex: 4560, + lcp: 8715, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/thanks$', + pagePerfThresholds: { + fcp: 4560, + speedIndex: 4725, + lcp: 9075, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/volunteer$', + pagePerfThresholds: { + fcp: 4350, + speedIndex: 5145, + lcp: 9615, + tbt: 300, + cls: 0.59, + }, + accessibilityMinScore: 0.88, + overrides: { + 'uses-responsive-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/topics-and-skills-dashboard$', + pagePerfThresholds: { + fcp: 2775, + speedIndex: 4695, + lcp: 9510, + tbt: 300, + cls: 0.15, + }, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + accessibilityMinScore: 0.9, + }, + { + matchingUrlPattern: + '^http://localhost:8181/learn/staging/dummy-topic-one/story$', + pagePerfThresholds: { + fcp: 2775, + speedIndex: 4815, + lcp: 9810, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.88, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: + '^http://localhost:8181/learn/staging/dummy-topic-one/story/help-jamie-win-arcade$', + pagePerfThresholds: { + fcp: 2775, + speedIndex: 5370, + lcp: 10290, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.88, + overrides: { + 'errors-in-console': ['error', {minScore: 0}], + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: '^http://localhost:8181/learn/math$', + pagePerfThresholds: { + fcp: 2835, + speedIndex: 5145, + lcp: 9540, + tbt: 300, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + overrides: { + // Classroom pages use JPEG images that are not next-gen formats. + 'modern-image-formats': [ + 'error', + {maxLength: 1, strategy: 'pessimistic'}, + ], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/create/.*$', + pagePerfThresholds: { + fcp: 2790, + speedIndex: 5685, + lcp: 16500, + tbt: 915, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + overrides: { + 'errors-in-console': ['error', {minScore: 1}], + // TODO(#13465): Change this maxLength to 0 once images are + // migrated. + 'modern-image-formats': [ + 'error', + {maxLength: 3, strategy: 'pessimistic'}, + ], + // We need to use passive event listeners on this page so that + // the page works correctly. + 'uses-passive-event-listeners': ['error', {minScore: 0}], + // MIDI library uses some deprecated API. + deprecations: ['error', {minScore: 0}], + redirects: ['error', {minScore: 1}], + 'uses-responsive-images': ['error', {minScore: 1}], + }, + }, + { + matchingUrlPattern: '^http://localhost:8181/explore/.*$', + pagePerfThresholds: { + fcp: 2775, + speedIndex: 2775, + lcp: 14805, + tbt: 810, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + overrides: { + // Explore page uses deprecated APIs from third-party scripts. + deprecations: ['error', {minScore: 0}], + // Explore page has images that are not in next-gen formats. + 'modern-image-formats': [ + 'error', + {maxLength: 3, strategy: 'pessimistic'}, + ], + // All images are offscreen on the explore page. + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/topic_editor/.*$', + pagePerfThresholds: { + fcp: 2775, + speedIndex: 2775, + lcp: 11580, + tbt: 420, + cls: 0.15, + }, + accessibilityMinScore: 0.92, + }, + { + matchingUrlPattern: 'http://[^/]+/skill_editor/.*$', + pagePerfThresholds: { + fcp: 2775, + speedIndex: 2775, + lcp: 11865, + tbt: 390, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: '^http://[^/]+/story_editor/.*$', + pagePerfThresholds: { + fcp: 2850, + speedIndex: 2850, + lcp: 9690, + tbt: 330, + cls: 0.15, + }, + accessibilityMinScore: 0.84, + }, + ], + // Error-level safety-net thresholds for desktop. Per-page entries + // override these at tighter values. + { + fcp: 5550, + speedIndex: 6150, + lcp: 16500, + tbt: 975, + cls: 1.0, + } + ), + }, + upload: { + target: 'temporary-public-storage', + }, + }, +}; diff --git a/.lighthouserc-performance.js b/.lighthouserc-performance.js deleted file mode 100644 index a0faddb99d0f2..0000000000000 --- a/.lighthouserc-performance.js +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright 2020 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview Configuration for lighthouse-ci. - */ - -const baseConfig = require('./.lighthouserc-base.js'); - -module.exports = { - ci: { - collect: { - numberOfRuns: baseConfig['numberOfRuns'], - puppeteerScript: baseConfig['puppeteerScript'], - url: baseConfig['urls'], - }, - assert: { - assertMatrix: [ - baseConfig['basePerformanceAssertMatrix'], - { - matchingUrlPattern: 'http://[^/]+/$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/admin$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: '^http://localhost:8181/blog-dashboard$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/about$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/community-library$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/contact$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/contributor-dashboard$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/creator-dashboard$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/creator-guidelines$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/delete-account$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/donate$', - assertions: { - // TODO(#17279): There is an error on the /donate page due to the - // embedded Stripe third-party component within it. Find a way to - // ignore that error. - 'errors-in-console': ['error', {minScore: 0}], - // The YouTube embed on donate page loads images in jpg format, thus - // we need to allow one image. - 'modern-image-formats': [ - 'error', - {maxLength: 1, strategy: 'pessimistic'}, - ], - // The YouTube embed on donate page uses passive listeners. - 'uses-passive-event-listeners': ['error', {minScore: 0}], - 'uses-rel-preload': ['error', {minScore: 1}], - // TODO(#20286): There is a deprecated API on the /donate page due to the - // donorbox script, change the minScore to 1 once it is fixed. - deprecations: ['error', {minScore: 0}], - redirects: ['error', {minScore: 1}], - 'uses-responsive-images': ['error', {minScore: 0.8}], - }, - }, - { - matchingUrlPattern: 'http://[^/]+/get-started$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/license$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/moderator$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/learner-dashboard$', - assertions: { - 'errors-in-console': ['error', {minScore: 1}], - 'modern-image-formats': [ - 'error', - {maxLength: 0, strategy: 'pessimistic'}, - ], - // We need to use passive event listeners on this page so that - // the page works correctly. - 'uses-passive-event-listeners': ['error', {minScore: 0}], - // Sign up redirects logged-in user to learner dashboard page. - // Learner dashboard Page cannot be preloaded. - 'uses-rel-preload': ['error', {minScore: 0}], - deprecations: ['error', {minScore: 1}], - redirects: ['error', {minScore: 0}], - 'uses-responsive-images': ['error', {minScore: 0.8}], - }, - }, - { - matchingUrlPattern: 'http://[^/]+/preferences$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/privacy-policy$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/profile/username1$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/signup?return_url=%2F$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/teach$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/terms$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/thanks$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/volunteer$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/topics-and-skills-dashboard$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/create/.*$', - assertions: { - 'errors-in-console': ['error', {minScore: 1}], - // TODO(#13465): Change this maxLength to 0 once images are migrated. - 'modern-image-formats': [ - 'error', - {maxLength: 3, strategy: 'pessimistic'}, - ], - // We need to use passive event listeners on this page so that - // the page works correctly. - 'uses-passive-event-listeners': ['error', {minScore: 0}], - // MIDI library uses some deprecated API. - deprecations: ['error', {minScore: 0}], - 'uses-rel-preload': ['error', {minScore: 1}], - redirects: ['error', {minScore: 1}], - 'uses-responsive-images': ['error', {minScore: 1}], - }, - }, - { - matchingUrlPattern: 'http://[^/]+/topic_editor/.*$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: 'http://[^/]+/skill_editor/.*$', - assertions: baseConfig['basePerformanceAssertions'], - }, - { - matchingUrlPattern: '^http://[^/]+/story_editor/.*$', - assertions: baseConfig['basePerformanceAssertions'], - }, - ], - }, - upload: { - target: 'temporary-public-storage', - }, - }, -}; diff --git a/.lighthouserc.js b/.lighthouserc.js new file mode 100644 index 0000000000000..211615102ee37 --- /dev/null +++ b/.lighthouserc.js @@ -0,0 +1,500 @@ +// Copyright 2020 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Configuration for lighthouse-ci (mobile). + */ + +const baseConfig = require('./.lighthouserc-base.js'); + +module.exports = { + ci: { + collect: { + numberOfRuns: baseConfig['numberOfRuns'], + puppeteerScript: baseConfig['puppeteerScript'], + url: baseConfig['urls'], + settings: { + onlyCategories: [ + 'performance', + 'accessibility', + 'best-practices', + 'seo', + ], + }, + }, + assert: { + assertMatrix: baseConfig.buildAssertMatrix( + [ + { + matchingUrlPattern: 'http://[^/]+/$', + pagePerfThresholds: { + fcp: 16050, + speedIndex: 16050, + lcp: 56550, + tbt: 1575, + cls: 0.15, + }, + }, + { + matchingUrlPattern: 'http://[^/]+/about$', + pagePerfThresholds: { + fcp: 30750, + speedIndex: 30750, + lcp: 63000, + tbt: 1245, + cls: 0.6, + }, + accessibilityMinScore: 0.88, + }, + { + matchingUrlPattern: 'http://[^/]+/admin$', + pagePerfThresholds: { + fcp: 16125, + speedIndex: 16125, + lcp: 63000, + tbt: 1815, + cls: 0.15, + }, + accessibilityMinScore: 0.93, + }, + { + matchingUrlPattern: '^http://localhost:8181/blog-dashboard$', + pagePerfThresholds: { + fcp: 15900, + speedIndex: 18600, + lcp: 52650, + tbt: 2085, + cls: 0.15, + }, + accessibilityMinScore: 0.92, + }, + { + matchingUrlPattern: 'http://[^/]+/community-library$', + pagePerfThresholds: { + fcp: 16125, + speedIndex: 21750, + lcp: 67500, + tbt: 2070, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: 'http://[^/]+/contact$', + pagePerfThresholds: { + fcp: 24750, + speedIndex: 24750, + lcp: 51900, + tbt: 1463, + cls: 1.11, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/contributor-dashboard$', + pagePerfThresholds: { + fcp: 16650, + speedIndex: 24750, + lcp: 63750, + tbt: 1935, + cls: 0.23, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/creator-dashboard$', + pagePerfThresholds: { + fcp: 15900, + speedIndex: 19050, + lcp: 53550, + tbt: 2175, + cls: 0.15, + }, + accessibilityMinScore: 0.88, + }, + { + matchingUrlPattern: 'http://[^/]+/creator-guidelines$', + pagePerfThresholds: { + fcp: 24750, + speedIndex: 24750, + lcp: 57750, + tbt: 1463, + cls: 0.78, + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: 'http://[^/]+/delete-account$', + pagePerfThresholds: { + fcp: 25500, + speedIndex: 25500, + lcp: 52350, + tbt: 1785, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/donate$', + pagePerfThresholds: { + fcp: 24750, + speedIndex: 24750, + lcp: 63000, + tbt: 4800, + cls: 1.5, + }, + overrides: { + 'errors-in-console': ['error', {minScore: 0}], + // The YouTube embed on donate page loads images in jpg format, + // thus we need to allow one image. + 'modern-image-formats': [ + 'error', + {maxLength: 1, strategy: 'pessimistic'}, + ], + // The YouTube embed on donate page uses passive listeners. + 'uses-passive-event-listeners': ['error', {minScore: 0}], + deprecations: ['error', {minScore: 0}], + redirects: ['error', {minScore: 1}], + 'uses-responsive-images': ['error', {minScore: 0.5}], + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: 'http://[^/]+/get-started$', + pagePerfThresholds: { + fcp: 24750, + speedIndex: 24750, + lcp: 51300, + tbt: 1440, + cls: 0.98, + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: 'http://[^/]+/learner-dashboard$', + pagePerfThresholds: { + fcp: 16065, + speedIndex: 16065, + lcp: 56850, + tbt: 2340, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + overrides: { + 'errors-in-console': ['error', {minScore: 1}], + 'modern-image-formats': [ + 'error', + {maxLength: 0, strategy: 'pessimistic'}, + ], + // We need to use passive event listeners on this page so that + // the page works correctly. + 'uses-passive-event-listeners': ['error', {minScore: 0}], + // Sign up redirects logged-in user to learner dashboard page. + // Learner dashboard Page cannot be preloaded. + deprecations: ['error', {minScore: 1}], + redirects: ['error', {minScore: 0}], + 'uses-responsive-images': ['error', {minScore: 0.8}], + // All images are offscreen on the learner dashboard. + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/license$', + pagePerfThresholds: { + fcp: 25545, + speedIndex: 25545, + lcp: 52050, + tbt: 1170, + cls: 0.68, + }, + accessibilityMinScore: 0.91, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/moderator$', + pagePerfThresholds: { + fcp: 15465, + speedIndex: 15465, + lcp: 57750, + tbt: 2625, + cls: 0.15, + }, + accessibilityMinScore: 0.92, + }, + { + matchingUrlPattern: 'http://[^/]+/preferences$', + pagePerfThresholds: { + fcp: 16365, + speedIndex: 16365, + lcp: 55200, + tbt: 2925, + cls: 0.15, + }, + accessibilityMinScore: 0.84, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/privacy-policy$', + pagePerfThresholds: { + fcp: 24315, + speedIndex: 24315, + lcp: 63000, + tbt: 1155, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/profile/username1$', + pagePerfThresholds: { + fcp: 16140, + speedIndex: 16140, + lcp: 53400, + tbt: 2700, + cls: 0.18, + }, + accessibilityMinScore: 0.95, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/signup\\?return_url=%2F$', + pagePerfThresholds: { + fcp: 25500, + speedIndex: 25500, + lcp: 57000, + tbt: 2400, + cls: 0.15, + }, + }, + { + matchingUrlPattern: 'http://[^/]+/teach$', + pagePerfThresholds: { + fcp: 15450, + speedIndex: 15450, + lcp: 53700, + tbt: 1815, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + overrides: { + 'uses-responsive-images': ['error', {minScore: 0.5}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/terms$', + pagePerfThresholds: { + fcp: 24300, + speedIndex: 24300, + lcp: 51000, + tbt: 1020, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + }, + { + matchingUrlPattern: 'http://[^/]+/thanks$', + pagePerfThresholds: { + fcp: 30900, + speedIndex: 30900, + lcp: 52350, + tbt: 1380, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/volunteer$', + pagePerfThresholds: { + fcp: 30750, + speedIndex: 30750, + lcp: 53250, + tbt: 1755, + cls: 1.13, + }, + accessibilityMinScore: 0.88, + overrides: { + 'uses-responsive-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/topics-and-skills-dashboard$', + pagePerfThresholds: { + fcp: 19800, + speedIndex: 19800, + lcp: 60750, + tbt: 2595, + cls: 0.15, + }, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + accessibilityMinScore: 0.9, + }, + { + matchingUrlPattern: + '^http://localhost:8181/learn/staging/dummy-topic-one/story$', + pagePerfThresholds: { + fcp: 15480, + speedIndex: 21540, + lcp: 57945, + tbt: 2265, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + overrides: { + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: + '^http://localhost:8181/learn/staging/dummy-topic-one/story/help-jamie-win-arcade$', + pagePerfThresholds: { + fcp: 15915, + speedIndex: 15915, + lcp: 59250, + tbt: 2130, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + overrides: { + 'errors-in-console': ['error', {minScore: 0}], + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: '^http://localhost:8181/learn/math$', + pagePerfThresholds: { + fcp: 15945, + speedIndex: 20250, + lcp: 54330, + tbt: 2235, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + overrides: { + // Classroom pages use JPEG images that are not next-gen formats. + 'modern-image-formats': [ + 'error', + {maxLength: 1, strategy: 'pessimistic'}, + ], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/create/.*$', + pagePerfThresholds: { + fcp: 16125, + speedIndex: 30675, + lcp: 97110, + tbt: 3870, + cls: 0.15, + }, + accessibilityMinScore: 0.95, + overrides: { + 'errors-in-console': ['error', {minScore: 1}], + // TODO(#13465): Change this maxLength to 0 once images are + // migrated. + 'modern-image-formats': [ + 'error', + {maxLength: 3, strategy: 'pessimistic'}, + ], + // We need to use passive event listeners on this page so that + // the page works correctly. + 'uses-passive-event-listeners': ['error', {minScore: 0}], + // MIDI library uses some deprecated API. + deprecations: ['error', {minScore: 0}], + redirects: ['error', {minScore: 1}], + 'uses-responsive-images': ['error', {minScore: 1}], + }, + }, + { + matchingUrlPattern: '^http://localhost:8181/explore/.*$', + pagePerfThresholds: { + fcp: 16035, + speedIndex: 24180, + lcp: 84690, + tbt: 3945, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + overrides: { + // Explore page uses deprecated APIs from third-party scripts. + deprecations: ['error', {minScore: 0}], + // Explore page has images that are not in next-gen formats. + 'modern-image-formats': [ + 'error', + {maxLength: 3, strategy: 'pessimistic'}, + ], + // All images are offscreen on the explore page. + 'offscreen-images': ['error', {minScore: 0}], + }, + }, + { + matchingUrlPattern: 'http://[^/]+/topic_editor/.*$', + pagePerfThresholds: { + fcp: 15915, + speedIndex: 25725, + lcp: 67530, + tbt: 2325, + cls: 0.15, + }, + accessibilityMinScore: 0.92, + }, + { + matchingUrlPattern: 'http://[^/]+/skill_editor/.*$', + pagePerfThresholds: { + fcp: 15900, + speedIndex: 25515, + lcp: 68145, + tbt: 2235, + cls: 0.15, + }, + accessibilityMinScore: 0.91, + }, + { + matchingUrlPattern: '^http://[^/]+/story_editor/.*$', + pagePerfThresholds: { + fcp: 15915, + speedIndex: 21060, + lcp: 55170, + tbt: 1845, + cls: 0.15, + }, + accessibilityMinScore: 0.84, + }, + ], + // Error-level safety-net thresholds for mobile. Per-page entries + // override these at tighter values. + { + fcp: 31000, + speedIndex: 31000, + lcp: 105000, + tbt: 4800, + cls: 1.5, + } + ), + }, + upload: { + target: 'temporary-public-storage', + }, + }, +}; diff --git a/AGENTS.md b/AGENTS.md index 60c1d4f5063bf..c3e22e931caae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,11 +51,9 @@ Project-specific conventions and patterns (discoverable in the tree): `core/storage` and platform helpers in `core/platform`. - Frontend: see the `core/templates` folder. -- Tests mapping: tests are split into backend unit tests (`core/tests`), - frontend unit tests, Puppeteer-based acceptance tests - (`core/tests/puppeteer-acceptance-tests`) and WebdriverIO e2e - (`core/tests/wdio.conf.js`). The WebdriverIO e2e tests are intended to be - deprecated in favour of the Puppeteer tests after we have confirmed parity - in coverage. + frontend unit tests, and Puppeteer/Playwright-based acceptance tests + (`core/tests/puppeteer-acceptance-tests` and + `core/tests/playwright-acceptance-tests`). -- Tooling pinning: Node and other tools are provided under `oppia_tools/` (e.g. `node-16.13.0`). In general we prefer using the pinned versions in CI and in local development so that we can keep the environment consistent. diff --git a/angular-template-style-url-replacer.webpack-loader.js b/angular-template-style-url-replacer.webpack-loader.js deleted file mode 100644 index 077185980ee6d..0000000000000 --- a/angular-template-style-url-replacer.webpack-loader.js +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2021 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview This is a webpack loader that replaces templateUrl: './html' - * with template: require('./html'). This is needed for our webpack based - * compilation and not the angular compiler. Angular compiler parses the html - * and converts it to js instructions. For the style urls, the angular compiler - * uses styleUrls while webpack uses imports. Hence, currently we put the - * stylesheet as import and as a styleUrl in the component. Once we have moved - * away from separate rtl css files, we will remove the import statements and - * just keep styleUrls. Until then, for webpack, we need remove styleUrls for - * webpack compilation. - */ - -/** - * The regexes are trying to find the templateUrl from the component decorator - * Eg: - * @Component({ - * selector: 'oppia-base-content', - * templateUrl: './base-content.component.html', - * styleUrls: ['./base-content.component.css'] - * }) - * - * From the above we need to get './base-content.component.html' and - * ['./base-content.component.css']. - * - * After modifications, it will look like: - * @Component({ - * selector: 'oppia-base-content', - * template: require('./base-content.component.html'), - * styleUrls: [] - * }) - * Templates can be found using the regex: - * templateUrl[any number of spaces]:[any number of spaces] - * [any of '"` that starts a string in javascript] - * [match all characters between the quotes][End quotes '"`] - * [any number of spaces] - * [ - * ends with a comma or a closing curly bracket depending or wether there are - * more items in the decorator or not - * ] - */ -const TEMPLATE_URL_REGEX = /templateUrl\s*:(\s*['"`](.*?)['"`]\s*([,}]))/gm; -const STYLES_URL_REGEX = /styleUrls *:(\s*\[[^\]]*?\])/g; -const VALUE_REGEX = /(['`"])((?:[^\\]\\\1|.)*?)\1/g; - -/** - * This function is only used for templateUrl modifications. From a string this - * function extracts the first value inside quotes ('"`). - * Example: For a string like: "templateUrl: './base-content.component.html'," - * The VALUE_REGEX will match "'./base-content.component.html'" and the first - * group is the quote ("'") and the second group is - * ""./base-content.component.html" - * @param {string} str - * @returns Relative url - */ -const replaceStringsWithRequiresStatement = str => { - return str.replace(VALUE_REGEX, function (_, __, url) { - return "require('" + url + "')"; - }); -}; - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -const loader = sourceString => { - // https://webpack.js.org/api/loaders/#thiscacheable - // Cacheable is an interface provided by webpack and is used to speed up - // the build by caching results. - this.cacheable && this.cacheable(); - /** - * The templateURL regex will match something like: - * "templateUrl: './base-content.component.html'," - */ - const newSourceString = sourceString - .replace(TEMPLATE_URL_REGEX, (_, url) => { - return 'template:' + replaceStringsWithRequiresStatement(url); - }) - .replace(STYLES_URL_REGEX, () => { - /** - * For the style urls, the angular compiler - * uses styleUrls while webpack uses imports. Hence, currently we put the - * stylesheet as import and as a styleUrl in the component. Once we have - * moved away from separate rtl css files, we will remove the import - * statements and just keep styleUrls. Until then, for webpack, we need - * remove styleUrls property for webpack compilation. - */ - return 'styleUrl: []'; - }); - - return newSourceString; -}; - -module.exports = loader; diff --git a/angular.json b/angular.json index 33c2b8136f30f..6f6254c7b9f63 100644 --- a/angular.json +++ b/angular.json @@ -11,7 +11,7 @@ } }, "root": "", - "sourceRoot": "src", + "sourceRoot": "", "prefix": "app", "architect": { "build": { @@ -157,13 +157,26 @@ "test": { "builder": "@angular-devkit/build-angular:karma", "options": { - "main": "src/test.ts", + "main": "core/templates/combined-tests.spec.ts", "polyfills": "core/templates/Polyfills.ts", "tsConfig": "tsconfig.spec.json", - "karmaConfig": "karma.conf.js", + "karmaConfig": "core/tests/karma.conf.ts", + "codeCoverageExclude": [ + "core/templates/expressions/parser.js", + "core/templates/Polyfills.ts" + ], "assets": [ "src/favicon.ico", - "assets", + { + "glob": "**/*", + "input": "assets/", + "output": "/assets/" + }, + { + "glob": "**/*", + "input": "extensions/", + "output": "/extensions/" + }, { "glob": "**/*", "input": "node_modules/mathjax", @@ -181,15 +194,34 @@ "bundleName": "vendor-styles", "inject": true }, + { + "input": "node_modules/charts.css/dist/charts.css", + "bundleName": "charts-library", + "inject": true + }, "core/templates/css/oppia.css", "core/templates/css/oppia-material.css", "node_modules/ngx-toastr/toastr.css", "node_modules/shepherd.js/dist/css/shepherd.css", - "core/templates/css/shepherd-overrides.css" + "core/templates/css/shepherd-overrides.css", + "node_modules/guppy-dev/build/guppy-default.min.css", + "node_modules/cropperjs/dist/cropper.min.css", + "node_modules/codemirror/lib/codemirror.css", + "node_modules/codemirror/addon/merge/merge.css" ], "scripts": [ "node_modules/bootstrap/dist/js/bootstrap.bundle.js" ] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] + } } }, "lint": { @@ -203,18 +235,6 @@ "extensions/**/*.html" ] } - }, - "e2e": { - "builder": "@angular-devkit/build-angular:protractor", - "options": { - "protractorConfig": "e2e/protractor.conf.js", - "devServerTarget": "oppia-angular:serve" - }, - "configurations": { - "production": { - "devServerTarget": "oppia-angular:serve:production" - } - } } } } diff --git a/assets/i18n/en.json b/assets/i18n/en.json index b3a24dd0057db..5a7268f2b30f4 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -145,6 +145,11 @@ "I18N_ACTION_EXPLORE_LESSONS": "Explore lessons", "I18N_ACTION_GET_ANDRIOD_APP": "Get the Android App", "I18N_ACTION_VIEW_LESSONS": "View lessons", + "I18N_ADD_A_FOLLOW_UP_NOTE_HEADER_INFO": "A creator marked this as fixed. Let them know if it's resolved, or add anything else you'd like them to know.", + "I18N_ADD_A_FOLLOW_UP_NOTE_HEADER_TITLE": "Add a follow-up note", + "I18N_ADD_A_FOLLOW_UP_NOTE_ORIGINAL_LABEL": "Your original feedback:", + "I18N_ADD_A_FOLLOW_UP_NOTE_ORIGINAL_NOTE":"Your note", + "I18N_ADD_A_FOLLOW_UP_NOTE_SUBMIT_BUTTON": "Send note", "I18N_ADD_NEW_SYLLABUS_CANCEL_BUTTON_TEXT": "Cancel", "I18N_ADD_NEW_SYLLABUS_DONE_BUTTON_TEXT": "Done", "I18N_ADD_NEW_SYLLABUS_ITEMS": "New Syllabus Items", @@ -217,6 +222,7 @@ "I18N_BACK_TO_CLASSROOM": "Back to <[classroomName]>", "I18N_BACK_TO_CLASSROOMS": "Back to Classrooms", "I18N_BACK_TO_HOME_TEXT": "Back to Home", + "I18N_BACK_TO_MY_SUGGESTIONS": "Back to my suggestions", "I18N_BLOG_AUTHOR_PROFILE_PAGE_BREADCRUMB": "Author Profile", "I18N_BLOG_AUTHOR_PROFILE_PAGE_TITLE": "Blog | Author | Oppia", "I18N_BLOG_AUTHOR_PROFILE_PAGE_TOTAL_POSTS_DISPLAY": "<[totalNumber]> posts", @@ -417,6 +423,7 @@ "I18N_DASHBOARD_OPEN_FEEDBACK": "Open Feedback", "I18N_DASHBOARD_SKILL_PROFICIENCY": "Skill Proficiency", "I18N_DASHBOARD_STATS_AVERAGE_RATING": "Average Rating", + "I18N_DASHBOARD_STATS_FEEDBACK": "Feedback Stats", "I18N_DASHBOARD_STATS_OPEN_FEEDBACK": "Open Feedback", "I18N_DASHBOARD_STATS_TOTAL_PLAYS": "Total Plays", "I18N_DASHBOARD_STATS_TOTAL_SUBSCRIBERS": "Subscribers", @@ -962,6 +969,8 @@ "I18N_LEARNER_DASHBOARD_MY_CERTIFICATES_TABLE_SCORE_HEADING": "Score", "I18N_LEARNER_DASHBOARD_MY_CERTIFICATES_TABLE_STATUS_HEADING": "Status", "I18N_LEARNER_DASHBOARD_MY_CERTIFICATES_TABLE_SUBJECT_HEADING": "Subject", + "I18N_LEARNER_DASHBOARD_MY_SUGGESTIONS_SECTION": "My Suggestions", + "I18N_LEARNER_DASHBOARD_MY_SUGGESTIONS_SECTION_HEADING": "<[username]>'s Suggestions", "I18N_LEARNER_DASHBOARD_NEW_STORY_CONTENT": "New Story content available", "I18N_LEARNER_DASHBOARD_NO_ACTIVITY_IN_EXPLORATION": "It looks like you haven't started any explorations yet. Head over to the library to start an exciting new exploration!", "I18N_LEARNER_DASHBOARD_PAGE_TITLE": "Learner Dashboard | Oppia", @@ -1160,6 +1169,12 @@ "I18N_MODAL_REMOVE_BUTTON": "Remove", "I18N_MULTIPLE_CLASSROOMS_PAGE_DESCRIPTION": "Step into Oppia Classrooms and explore educational materials curated by our team with your learning journey in mind!", "I18N_MULTIPLE_LESSONS": "Lessons", + "I18N_MY_SUGGESTION_CREATOR_THREAD_HEADER": "Creator response", + "I18N_MY_SUGGESTION_LEARNER_THREAD_HEADER": "Your feedback", + "I18N_MY_SUGGESTION_LESSON_METADATA_TEXT_MIDDLE": "this lesson", + "I18N_MY_SUGGESTION_LESSON_METADATA_TEXT_START": "You sent this while going through", + "I18N_MY_SUGGESTION_STATUS_LABEL": "Status:", + "I18N_MY_SUGGESTION_SUBMITTED_ON": "Submitted on:", "I18N_NEW_HINT_TITLE": "Hint <[hintIndex]>", "I18N_NEXT_LESSON": "Next Lesson", "I18N_NO": "No", diff --git a/assets/i18n/qqq.json b/assets/i18n/qqq.json index 2e7af694db15c..c400cea4b7ba9 100644 --- a/assets/i18n/qqq.json +++ b/assets/i18n/qqq.json @@ -145,6 +145,11 @@ "I18N_ACTION_EXPLORE_LESSONS": "Third button in last section of Oppia's About page targeting lessons in library.", "I18N_ACTION_GET_ANDRIOD_APP": "Text displayed on the Android button of the About page which on click redirects users to the Android page.", "I18N_ACTION_VIEW_LESSONS": "Text on a button in Oppia's teach page targeting lessons in library.", + "I18N_ADD_A_FOLLOW_UP_NOTE_HEADER_INFO": "Text displayed in the info section of the add a follow up note dialog.", + "I18N_ADD_A_FOLLOW_UP_NOTE_HEADER_TITLE": "Text displayed in the header of the add a follow up note dialog.", + "I18N_ADD_A_FOLLOW_UP_NOTE_ORIGINAL_LABEL": "Text displayed in the original note label of the add a follow up note dialog.", + "I18N_ADD_A_FOLLOW_UP_NOTE_ORIGINAL_NOTE": "Text displayed in the original note section of the add a follow up note dialog.", + "I18N_ADD_A_FOLLOW_UP_NOTE_SUBMIT_BUTTON": "Text displayed on the submit button of the add a follow up note dialog.", "I18N_ADD_NEW_SYLLABUS_CANCEL_BUTTON_TEXT": "Cancel button text on the add new syllabus items section on the edit learner group page.", "I18N_ADD_NEW_SYLLABUS_DONE_BUTTON_TEXT": "Text of button to be clicked when user is done adding new syllabus items on the add new syllabus items section on the edit learner group page.", "I18N_ADD_NEW_SYLLABUS_ITEMS": "Button text of the add new syllabus items button on the syllabus tab of the edit learner group page.", @@ -217,6 +222,7 @@ "I18N_BACK_TO_CLASSROOM": "Text which we show in topic viewer page mobile breadcrumbs to redirect to the classroom page.", "I18N_BACK_TO_CLASSROOMS": "Text which we show in classroom page mobile breadcrumbs if we have more than one public classrooms.", "I18N_BACK_TO_HOME_TEXT": "Text displayed in breadcrumbs - shown in the top left corner.", + "I18N_BACK_TO_MY_SUGGESTIONS": "Text displayed in the My Suggestions Page for going back to the My Suggestions list Page.", "I18N_BLOG_AUTHOR_PROFILE_PAGE_BREADCRUMB": "Text displayed in the Blog Author Profile Page. - Text shown in the top left corner of the nav bar.", "I18N_BLOG_AUTHOR_PROFILE_PAGE_TITLE": "Title displayed on the browser tab when on the Blog Author Profile.", "I18N_BLOG_AUTHOR_PROFILE_PAGE_TOTAL_POSTS_DISPLAY": "Text displayed in the blog author profile page - Text that displays the total number of blog posts written by the author on the author profile page.", @@ -417,6 +423,7 @@ "I18N_DASHBOARD_OPEN_FEEDBACK": "Text of feedback icon shown in summary tile of an exploration in card view in the user's dashboard page.", "I18N_DASHBOARD_SKILL_PROFICIENCY": "Title for the skill proficiency subsection inside the progress tab in mobile view on learner dashboard page.", "I18N_DASHBOARD_STATS_AVERAGE_RATING": "Text shown on the stats card on the creator dashboard - This is a label for the Average Rating of the creator", + "I18N_DASHBOARD_STATS_FEEDBACK": "Text shown on the stats card on the creator dashboard - This is a label for the Feedback stats of the feedbacks of that exploration.", "I18N_DASHBOARD_STATS_OPEN_FEEDBACK": "Text shown on the stats card on the creator dashboard - This is a label for the Open Feedback threads concerning the creator.", "I18N_DASHBOARD_STATS_TOTAL_PLAYS": "Text shown on the stats card on the creator dashboard - This is a label for the Total Plays of a creator's explorations.", "I18N_DASHBOARD_STATS_TOTAL_SUBSCRIBERS": "Text shown on the stats card on the creator dashboard - This is a label for the total subscriber count of a creator.\n{{Identical|Subscriber}}", @@ -962,6 +969,8 @@ "I18N_LEARNER_DASHBOARD_MY_CERTIFICATES_TABLE_SCORE_HEADING": "Column heading for the attempt score in the 'My Certificates' table in the learner dashboard.", "I18N_LEARNER_DASHBOARD_MY_CERTIFICATES_TABLE_STATUS_HEADING": "Column heading for the attempt status in the 'My Certificates' table in the learner dashboard.", "I18N_LEARNER_DASHBOARD_MY_CERTIFICATES_TABLE_SUBJECT_HEADING": "Column heading for the subject in the 'My Certificates' table in the learner dashboard.", + "I18N_LEARNER_DASHBOARD_MY_SUGGESTIONS_SECTION": "Text for the 'My Suggestions' section in the learner dashboard.", + "I18N_LEARNER_DASHBOARD_MY_SUGGESTIONS_SECTION_HEADING": "Heading for the 'My Suggestions' section in the learner dashboard, personalized with the learner's username.", "I18N_LEARNER_DASHBOARD_NEW_STORY_CONTENT": "Text displayed in the learner dashboard. - This text is shown when a new story content has been added to a topic in the completed goals section.", "I18N_LEARNER_DASHBOARD_NO_ACTIVITY_IN_EXPLORATION": "Text displayed in the learner dashboard. - This text is shown when the user hasn't played an exploration before and is checking the 'incomplete explorations' section.", "I18N_LEARNER_DASHBOARD_PAGE_TITLE": "Text displayed on the browser tab when on the Learner dashboard page.", @@ -1160,6 +1169,12 @@ "I18N_MODAL_REMOVE_BUTTON": "Text that is displayed in a button of a modal. On clicking it the user confirms to remove a syllabus item from the learner group syllabus.", "I18N_MULTIPLE_CLASSROOMS_PAGE_DESCRIPTION": "The text which is displayed in the classrooms page. Gives information about classrooms.", "I18N_MULTIPLE_LESSONS": "Text for multiple lessons", + "I18N_MY_SUGGESTION_CREATOR_THREAD_HEADER": "Text displayed on the message header for the creator thread.", + "I18N_MY_SUGGESTION_LEARNER_THREAD_HEADER": "Text displayed on the message header for the learner thread.", + "I18N_MY_SUGGESTION_LESSON_METADATA_TEXT_MIDDLE": "Middle part of text displayed above the thread messages to give lesson context to the user.", + "I18N_MY_SUGGESTION_LESSON_METADATA_TEXT_START": "Starting part of text displayed above the thread messages to give lesson context to the user.", + "I18N_MY_SUGGESTION_STATUS_LABEL": "Label displayed for the status of the feedback.", + "I18N_MY_SUGGESTION_SUBMITTED_ON": "Label displayed for the date when the feedback was submitted.", "I18N_NEW_HINT_TITLE": "Title of the modal that shows a hint to the learner.", "I18N_NEXT_LESSON": "Title of the section that shows the next lesson in a story once a chapter is completed.", "I18N_NO": "Text on button to reject the content of a modal.\n\n{{Identical|No}}", diff --git a/core/controllers/contributor_dashboard_admin.py b/core/controllers/contributor_dashboard_admin.py index 3f255395c1386..012a231e8fc49 100644 --- a/core/controllers/contributor_dashboard_admin.py +++ b/core/controllers/contributor_dashboard_admin.py @@ -649,7 +649,6 @@ def get(self, contribution_type: str, contribution_subtype: str) -> None: } else: - assert sort_by is not None translation_coordinator_dicts = contribution_stats_services.get_all_translation_coordinator_stats( sort_by ) @@ -777,9 +776,9 @@ def get_translation_coordinator_frontend_dict( # Here we use MyPy ignore because MyPy doesn't allow key addition # to TypedDict. stats_dict['reviewers_count'] = ( # type: ignore[typeddict-item] - community_stats.translation_reviewer_counts_by_lang_code[ - stats_dict['language_id'] - ] + community_stats.translation_reviewer_counts_by_lang_code.get( + stats_dict['language_id'], 0 + ) ) for coordinator_id in stats_dict['coordinator_ids']: diff --git a/core/controllers/general_feedback.py b/core/controllers/general_feedback.py index 45b8071c7c661..348a9ef33be35 100644 --- a/core/controllers/general_feedback.py +++ b/core/controllers/general_feedback.py @@ -76,7 +76,6 @@ class MyFeedbackListHandler( 'status': { 'schema': { 'type': 'basestring', - 'choices': feconf.STATUS_CHOICES, }, 'default_value': None, }, @@ -111,15 +110,33 @@ def get(self) -> None: assert self.normalized_request is not None req = self.normalized_request + + status_filter = req.get('status') + status_filters = status_filter.split(',') if status_filter else None + + if status_filters: + invalid_status_filters = [ + status + for status in status_filters + if status not in feconf.STATUS_CHOICES + ] + if invalid_status_filters: + raise self.InvalidInputException( + 'Invalid status filter values: %s. Expected statuses ' + 'to be chosen from feconf.STATUS_CHOICES: %s.' + % (invalid_status_filters, feconf.STATUS_CHOICES) + ) + summaries, next_cursor, more = ( general_feedback_services.get_learner_feedback_summaries( author_id=self.user_id, - status_filter=req.get('status'), + status_filter=status_filters, cursor=req.get('cursor'), date_from_msecs=req.get('date_from_msecs'), date_to_msecs=req.get('date_to_msecs'), ) ) + self.render_json( { 'summaries': summaries, @@ -214,6 +231,42 @@ def post(self, feedback_id: str) -> None: self.render_json({'success': True}) +class MyFeedbackUnreadCountHandler( + base.BaseHandler[Dict[str, str], Dict[str, str]] +): + """Handler for the learner's global unread feedback response count. + + GET /my_feedback/unread_count + + Returns the total number of unread creator responses across all of the + learner's lesson feedback entries, independent of list pagination. + + Access: + - Requires a logged-in learner. + """ + + GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON + URL_PATH_ARGS_SCHEMAS = {} + HANDLER_ARGS_SCHEMAS = {'GET': {}} + + @acl_decorators.open_access + def get(self) -> None: + """Returns the learner's global unread response count.""" + if self.user_id is None: + raise self.UnauthorizedUserException( + 'You must be logged in to view feedback.' + ) + self.render_json( + { + 'unread_count': ( + general_feedback_services.get_learner_unread_feedback_count( + author_id=self.user_id + ) + ) + } + ) + + class LessonFeedbackSubmitHandler( base.BaseHandler[ general_feedback_domain.FeedbackSubmitPayloadDict, @@ -474,6 +527,49 @@ def get( ) +class FeedbackStatusCountsHandler( + base.BaseHandler[Dict[str, str], Dict[str, str]] +): + """Handles retrieval of per-status counts of lesson feedback and issue + reports for an exploration. + + GET /feedbackstatuscounts/ + + URL path args: + exploration_id: str. The exploration ID to retrieve counts for. + + Response: + lesson_feedback_counts: dict. Maps each status in feconf.STATUS_CHOICES + to the number of lesson feedback entries with that status, plus a + 'total' key. + platform_report_counts: dict. Maps each status in feconf.STATUS_CHOICES + to the number of issue reports with that status, plus a 'total' + key. + + Access: + - Creator Dashboard: Requires edit access to the exploration. + """ + + GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON + URL_PATH_ARGS_SCHEMAS = { + 'exploration_id': { + 'schema': { + 'type': 'basestring', + }, + }, + } + HANDLER_ARGS_SCHEMAS = {'GET': {}} + + @acl_decorators.can_edit_exploration + def get(self, exploration_id: str) -> None: + """Returns per-status counts of feedback and reports.""" + self.render_json( + general_feedback_services.get_feedback_and_report_status_counts( + exploration_id + ) + ) + + class PlatformFeedbackSubmitHandler( base.BaseHandler[ general_feedback_domain.PlatformFeedbackSubmitPayloadDict, diff --git a/core/controllers/general_feedback_test.py b/core/controllers/general_feedback_test.py index 1eb111239319a..4ad8094d13804 100644 --- a/core/controllers/general_feedback_test.py +++ b/core/controllers/general_feedback_test.py @@ -28,7 +28,7 @@ ) from core.tests import test_utils -from typing import Optional +from typing import Dict, Optional class FeedbackSubmitHandlerTests(test_utils.GenericTestBase): @@ -61,6 +61,7 @@ def test_submit_lesson_feedback_successfully(self) -> None: response_list=[], unread_response_count=0, created_on_msecs=0, + last_updated_msecs=0, ) create_lesson_feedback_mock = mock.Mock(return_value=feedback) @@ -167,6 +168,103 @@ def test_learner_can_list_own_feedback(self) -> None: self.assertIsNone(response['next_cursor']) self.assertFalse(response['more']) + def _create_feedback_with_mixed_statuses(self) -> None: + """Creates three feedback entries with open, fixed and compliment + statuses for the viewer. + """ + general_feedback_services.create_lesson_feedback( + author_id=self.viewer_id, + feedback_text='Open feedback.', + lesson_metadata=self._get_lesson_metadata(), + ) + fixed_feedback = general_feedback_services.create_lesson_feedback( + author_id=self.viewer_id, + feedback_text='Fixed feedback.', + lesson_metadata=self._get_lesson_metadata(), + ) + general_feedback_services.update_lesson_feedback( + feedback_id=fixed_feedback.id, + new_status=feconf.STATUS_CHOICES_FIXED, + exp_id='exp_id', + responder_id=self.other_user_id, + ) + compliment_feedback = general_feedback_services.create_lesson_feedback( + author_id=self.viewer_id, + feedback_text='Compliment feedback.', + lesson_metadata=self._get_lesson_metadata(), + ) + general_feedback_services.update_lesson_feedback( + feedback_id=compliment_feedback.id, + new_status=feconf.STATUS_CHOICES_COMPLIMENT, + exp_id='exp_id', + responder_id=self.other_user_id, + ) + + def test_learner_can_list_own_feedback_without_status_filter(self) -> None: + self._create_feedback_with_mixed_statuses() + + with self.login_context(self.VIEWER_EMAIL): + response = self.get_json(feconf.MY_FEEDBACK_URL) + + self.assertEqual(len(response['summaries']), 3) + + def test_learner_can_list_own_feedback_with_single_status_filter( + self, + ) -> None: + self._create_feedback_with_mixed_statuses() + + with self.login_context(self.VIEWER_EMAIL): + response = self.get_json( + '%s?status=%s' + % (feconf.MY_FEEDBACK_URL, feconf.STATUS_CHOICES_FIXED) + ) + + self.assertEqual(len(response['summaries']), 1) + self.assertEqual( + response['summaries'][0]['status'], feconf.STATUS_CHOICES_FIXED + ) + + def test_learner_can_list_own_feedback_with_multiple_status_filters( + self, + ) -> None: + self._create_feedback_with_mixed_statuses() + + with self.login_context(self.VIEWER_EMAIL): + response = self.get_json( + '%s?status=%s,%s' + % ( + feconf.MY_FEEDBACK_URL, + feconf.STATUS_CHOICES_OPEN, + feconf.STATUS_CHOICES_COMPLIMENT, + ) + ) + + self.assertEqual(len(response['summaries']), 2) + returned_statuses = { + summary['status'] for summary in response['summaries'] + } + self.assertEqual( + returned_statuses, + {feconf.STATUS_CHOICES_OPEN, feconf.STATUS_CHOICES_COMPLIMENT}, + ) + + def test_learner_cannot_list_feedback_with_invalid_status_filter( + self, + ) -> None: + self._create_feedback_with_mixed_statuses() + + with self.login_context(self.VIEWER_EMAIL): + response = self.get_json( + '%s?status=%s,invalid_status' + % (feconf.MY_FEEDBACK_URL, feconf.STATUS_CHOICES_OPEN), + expected_status_int=400, + ) + + self.assertIn( + 'Invalid status filter values', + response['error'], + ) + def test_learner_can_get_own_feedback_detail(self) -> None: feedback = general_feedback_services.create_lesson_feedback( author_id=self.viewer_id, @@ -336,6 +434,110 @@ def test_logged_out_user_cannot_get_user_feedback_detail(self) -> None: ) +class MyFeedbackUnreadCountHandlerTests(test_utils.GenericTestBase): + """Tests for MyFeedbackUnreadCountHandler.""" + + def setUp(self) -> None: + super().setUp() + self.signup(self.VIEWER_EMAIL, self.VIEWER_USERNAME) + self.viewer_id = self.get_user_id_from_email(self.VIEWER_EMAIL) + self.signup('other@example.com', 'otheruser') + self.other_user_id = self.get_user_id_from_email('other@example.com') + + def _get_lesson_metadata( + self, + ) -> general_feedback_domain.LessonMetadataDict: + """Returns valid lesson metadata.""" + return { + 'exploration_id': 'exp_id', + 'exploration_version': 1, + 'state_name': 'Introduction', + 'state_index': 0, + 'learner_current_answer': None, + } + + def _respond_to_feedback( + self, + feedback_id: str, + responder_id: str, + ) -> None: + """Appends a creator response to the given feedback entry.""" + general_feedback_services.update_lesson_feedback( + feedback_id=feedback_id, + new_status=feconf.STATUS_CHOICES_OPEN, + exp_id='exp_id', + responder_id=responder_id, + reply_text='Creator response.', + ) + + def test_returns_total_unread_count_across_all_feedback(self) -> None: + first_feedback = general_feedback_services.create_lesson_feedback( + author_id=self.viewer_id, + feedback_text='First feedback.', + lesson_metadata=self._get_lesson_metadata(), + ) + second_feedback = general_feedback_services.create_lesson_feedback( + author_id=self.viewer_id, + feedback_text='Second feedback.', + lesson_metadata=self._get_lesson_metadata(), + ) + # Two responses on the first entry and one on the second. + self._respond_to_feedback(first_feedback.id, self.other_user_id) + self._respond_to_feedback(first_feedback.id, self.other_user_id) + self._respond_to_feedback(second_feedback.id, self.other_user_id) + + with self.login_context(self.VIEWER_EMAIL): + response = self.get_json(feconf.MY_FEEDBACK_UNREAD_COUNT_URL) + + self.assertEqual(response, {'unread_count': 3}) + + def test_excludes_other_users_feedback_from_unread_count(self) -> None: + other_feedback = general_feedback_services.create_lesson_feedback( + author_id=self.other_user_id, + feedback_text='Other learner feedback.', + lesson_metadata=self._get_lesson_metadata(), + ) + self._respond_to_feedback(other_feedback.id, self.viewer_id) + + with self.login_context(self.VIEWER_EMAIL): + response = self.get_json(feconf.MY_FEEDBACK_UNREAD_COUNT_URL) + + self.assertEqual(response, {'unread_count': 0}) + + def test_unread_count_drops_after_viewing_feedback_detail(self) -> None: + feedback = general_feedback_services.create_lesson_feedback( + author_id=self.viewer_id, + feedback_text='Helpful lesson.', + lesson_metadata=self._get_lesson_metadata(), + ) + self._respond_to_feedback(feedback.id, self.other_user_id) + + with self.login_context(self.VIEWER_EMAIL): + before = self.get_json(feconf.MY_FEEDBACK_UNREAD_COUNT_URL) + self.assertEqual(before, {'unread_count': 1}) + + self.get_json('%s/%s' % (feconf.MY_FEEDBACK_URL, feedback.id)) + + after = self.get_json(feconf.MY_FEEDBACK_UNREAD_COUNT_URL) + + self.assertEqual(after, {'unread_count': 0}) + + def test_returns_zero_when_learner_has_no_feedback(self) -> None: + with self.login_context(self.VIEWER_EMAIL): + response = self.get_json(feconf.MY_FEEDBACK_UNREAD_COUNT_URL) + + self.assertEqual(response, {'unread_count': 0}) + + def test_logged_out_user_cannot_get_unread_count(self) -> None: + response = self.get_json( + feconf.MY_FEEDBACK_UNREAD_COUNT_URL, expected_status_int=401 + ) + + self.assertEqual( + response['error'], 'You must be logged in to view feedback.' + ) + + class PlatformFeedbackSubmitHandlerTests(test_utils.GenericTestBase): """Tests for PlatformFeedbackSubmitHandler.""" @@ -1175,3 +1377,138 @@ def test_logged_out_user_cannot_get_lesson_feedback_detail(self) -> None: self.assertEqual( response['error'], 'You must be logged in to access this resource.' ) + + +class FeedbackStatusCountsHandlerTests(test_utils.GenericTestBase): + """Tests for FeedbackStatusCountsHandler.""" + + def setUp(self) -> None: + super().setUp() + self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) + self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + self.save_new_valid_exploration('exp_id', self.owner_id) + self.signup('other@example.com', 'otheruser') + self.other_user_id = self.get_user_id_from_email('other@example.com') + self.save_new_valid_exploration('exp_id_2', self.other_user_id) + + def _get_lesson_metadata( + self, + exp_id: str, + ) -> general_feedback_domain.LessonMetadataDict: + """Returns valid lesson metadata.""" + return { + 'exploration_id': exp_id, + 'exploration_version': 1, + 'state_name': 'Introduction', + 'state_index': 0, + 'learner_current_answer': None, + } + + def _get_empty_counts(self) -> Dict[str, int]: + """Returns a counts dict with every status set to zero.""" + counts: Dict[str, int] = {status: 0 for status in feconf.STATUS_CHOICES} + counts['total'] = 0 + return counts + + def test_returns_status_counts_for_lesson_feedback_and_reports( + self, + ) -> None: + general_feedback_services.create_lesson_feedback( + author_id=self.owner_id, + feedback_text='First open feedback.', + lesson_metadata=self._get_lesson_metadata('exp_id'), + ) + fixed_feedback = general_feedback_services.create_lesson_feedback( + author_id=self.owner_id, + feedback_text='Feedback to be marked as fixed.', + lesson_metadata=self._get_lesson_metadata('exp_id'), + ) + general_feedback_services.update_lesson_feedback( + feedback_id=fixed_feedback.id, + new_status=feconf.STATUS_CHOICES_FIXED, + exp_id='exp_id', + responder_id=self.owner_id, + ) + # Feedback for a different exploration must not be counted. + general_feedback_services.create_lesson_feedback( + author_id=self.owner_id, + feedback_text='Feedback for another exploration.', + lesson_metadata=self._get_lesson_metadata('exp_id_2'), + ) + + general_feedback_services.create_platform_report( + feedback_text='There is a typo.', + source='lesson', + page_url='https://oppia.org/learn', + category=feconf.CATEGORY_TYPO, + lesson_metadata=self._get_lesson_metadata('exp_id'), + session_info=None, + screenshot_filename=None, + screenshot_entity_id=None, + include_technical_logs=False, + ) + compliment_report = general_feedback_services.create_platform_report( + feedback_text='Great lesson.', + source='lesson', + page_url='https://oppia.org/learn', + category=feconf.CATEGORY_TYPO, + lesson_metadata=self._get_lesson_metadata('exp_id'), + session_info=None, + screenshot_filename=None, + screenshot_entity_id=None, + include_technical_logs=False, + ) + general_feedback_services.update_platform_feedback_status_for_dashboard( + report_id=compliment_report.id, + new_status=feconf.STATUS_CHOICES_COMPLIMENT, + dashboard=feconf.DESTINATION_CURRICULUM, + dashboard_id='exp_id', + ) + + with self.login_context(self.OWNER_EMAIL): + response = self.get_json('/feedbackstatuscounts/exp_id') + + expected_lesson_counts = self._get_empty_counts() + expected_lesson_counts[feconf.STATUS_CHOICES_OPEN] = 1 + expected_lesson_counts[feconf.STATUS_CHOICES_FIXED] = 1 + expected_lesson_counts['total'] = 2 + expected_report_counts = self._get_empty_counts() + expected_report_counts[feconf.STATUS_CHOICES_OPEN] = 1 + expected_report_counts[feconf.STATUS_CHOICES_COMPLIMENT] = 1 + expected_report_counts['total'] = 2 + + self.assertEqual( + response['lesson_feedback_counts'], expected_lesson_counts + ) + self.assertEqual( + response['platform_report_counts'], expected_report_counts + ) + + def test_returns_zero_counts_when_no_feedback_exists(self) -> None: + empty_counts = self._get_empty_counts() + + with self.login_context(self.OWNER_EMAIL): + response = self.get_json('/feedbackstatuscounts/exp_id') + + self.assertEqual(response['lesson_feedback_counts'], empty_counts) + self.assertEqual(response['platform_report_counts'], empty_counts) + + def test_logged_out_user_cannot_get_status_counts(self) -> None: + response = self.get_json( + '/feedbackstatuscounts/exp_id', expected_status_int=401 + ) + + self.assertEqual( + response['error'], 'You must be logged in to access this resource.' + ) + + def test_non_editor_cannot_get_status_counts(self) -> None: + with self.login_context('other@example.com'): + response = self.get_json( + '/feedbackstatuscounts/exp_id', expected_status_int=401 + ) + + self.assertEqual( + response['error'], + 'You do not have credentials to edit this exploration.', + ) diff --git a/core/domain/contribution_stats_services.py b/core/domain/contribution_stats_services.py index 7530c7074cdf2..81577e5d4cb4b 100644 --- a/core/domain/contribution_stats_services.py +++ b/core/domain/contribution_stats_services.py @@ -429,13 +429,13 @@ def get_question_reviewer_total_stats( def get_all_translation_coordinator_stats( - sort: str, + sort: Optional[str], ) -> List[user_domain.TranslationCoordinatorStats]: """Gets all TranslationCoordinatorStats corresponding to the supplied user and converts them to their corresponding domain objects. Args: - sort: str. The sort order for coordinator counts. + sort: str|None. The sort order for coordinator counts. Returns: list(TranslationCoordinatorStats). TranslationCoordinatorStats domain diff --git a/core/domain/email_manager.py b/core/domain/email_manager.py index 372f36cfdafd7..a1b33bc7f6951 100644 --- a/core/domain/email_manager.py +++ b/core/domain/email_manager.py @@ -721,6 +721,7 @@ def _send_email_transactional() -> None: taskqueue_services.enqueue_task( feconf.TASK_URL_RETRY_FAILED_EMAIL, payload, 0 ) + return email_models.SentEmailModel.create( recipient_id, diff --git a/core/domain/email_manager_test.py b/core/domain/email_manager_test.py index 13f1bbf55b653..9e1dec18de7f5 100644 --- a/core/domain/email_manager_test.py +++ b/core/domain/email_manager_test.py @@ -1887,6 +1887,7 @@ def _get_lesson_feedback( response_list=[], unread_response_count=0, created_on_msecs=0, + last_updated_msecs=0, ) def _get_platform_feedback( @@ -9823,3 +9824,31 @@ def mock_enqueue_task( enqueued_tasks[0][0], feconf.TASK_URL_RETRY_FAILED_EMAIL ) self.assertEqual(enqueued_tasks[0][1]['subject'], 'Subject') + + def test_failed_send_mail_does_not_create_sent_email_model(self) -> None: + """Test that a failed send does not create a SentEmailModel record.""" + + class SimulatedEmailFailure(Exception): + """Exception for simulating email failure.""" + + pass + + def mock_send_mail(*_args: str, **_kwargs: str) -> None: + raise SimulatedEmailFailure() + + send_mail_swap = self.swap(email_services, 'send_mail', mock_send_mail) + + models_before = len(email_models.SentEmailModel.query().fetch()) + + with send_mail_swap: + email_manager._send_email( # pylint: disable=protected-access + self.user_a_id, + feconf.SYSTEM_COMMITTER_ID, + feconf.EMAIL_INTENT_SIGNUP, + 'Subject', + 'Body', + 'sender@example.com', + ) + + models_after = len(email_models.SentEmailModel.query().fetch()) + self.assertEqual(models_before, models_after) diff --git a/core/domain/general_feedback_domain.py b/core/domain/general_feedback_domain.py index 9f4aee7c6539c..b820e6eb68f30 100644 --- a/core/domain/general_feedback_domain.py +++ b/core/domain/general_feedback_domain.py @@ -73,9 +73,12 @@ class LessonFeedbackSummaryDict(TypedDict): id: str feedback_text_preview: str + latest_response_preview: str status: str source: str + lesson_title: str unread_response_count: int + last_updated_msecs: float class LearnerLessonFeedbackDetailDict(TypedDict): @@ -161,6 +164,17 @@ class LessonFeedbackUpdatePayloadDict(TypedDict): reply_text: Optional[str] +class FeedbackStatusCountsDict(TypedDict): + """Per-status counts of lesson feedback and platform reports. + + Each inner dict maps every status in feconf.STATUS_CHOICES to the number + of non-deleted entries with that status, plus a 'total' key. + """ + + lesson_feedback_counts: Dict[str, int] + platform_report_counts: Dict[str, int] + + class LessonFeedback: """Domain object for a learner lesson feedback submission. @@ -183,6 +197,7 @@ class LessonFeedback: responded_on (responded_by is stored internally and never exported). unread_response_count: int. Total number of creator responses that have not yet been marked as read by the learner. created_on_msecs: float. Creation timestamp in milliseconds. + last_updated_msecs: float. Last update timestamp in milliseconds. """ def __init__( @@ -195,6 +210,7 @@ def __init__( response_list: List[LessonFeedbackResponseDict], unread_response_count: int, created_on_msecs: float, + last_updated_msecs: float, parent_feedback_id: Optional[str] = None, ) -> None: self.id = feedback_id @@ -205,6 +221,7 @@ def __init__( self.parent_feedback_id = parent_feedback_id self.response_list = response_list self.unread_response_count = unread_response_count + self.last_updated_msecs = last_updated_msecs self.created_on_msecs = created_on_msecs def to_dict(self) -> LessonFeedbackDict: @@ -224,9 +241,17 @@ def to_dict(self) -> LessonFeedbackDict: 'created_on_msecs': self.created_on_msecs, } - def to_summary_dict(self) -> LessonFeedbackSummaryDict: + def to_summary_dict( + self, lesson_title: Optional[str] + ) -> LessonFeedbackSummaryDict: """Returns a lightweight summary dict for use in list views. + Args: + lesson_title: Optional[str]. The title of the exploration this + feedback was submitted on, resolved by the caller via a + batched lookup (see get_learner_feedback_summaries). None + if the exploration could not be found (e.g. deleted). + Returns: LessonFeedbackSummaryDict. A summary dict representation of the object. @@ -234,12 +259,23 @@ def to_summary_dict(self) -> LessonFeedbackSummaryDict: feedback_text_preview = self.feedback_text if len(feedback_text_preview) > 100: feedback_text_preview = feedback_text_preview[:97] + '...' + latest_response_preview = '' + if self.response_list: + latest_response_text = self.response_list[-1]['response_text'] + latest_response_preview = latest_response_text + if len(latest_response_preview) > 100: + latest_response_preview = '%s...' % ( + latest_response_preview[:97] + ) return { 'id': self.id, 'feedback_text_preview': feedback_text_preview, + 'latest_response_preview': latest_response_preview, 'status': self.status, 'source': feconf.SOURCE_LESSON, + 'lesson_title': lesson_title or 'Lesson not found', 'unread_response_count': self.unread_response_count, + 'last_updated_msecs': self.last_updated_msecs, } def to_learner_dict(self) -> LearnerLessonFeedbackDetailDict: diff --git a/core/domain/general_feedback_domain_test.py b/core/domain/general_feedback_domain_test.py index 55fb38042ce1d..c2cf0eabd49a3 100644 --- a/core/domain/general_feedback_domain_test.py +++ b/core/domain/general_feedback_domain_test.py @@ -45,7 +45,7 @@ 'responded_on': 1700000001000.0, }, { - 'response_text': 'Pushed a fix in v4.', + 'response_text': 'N' * 110, 'responded_on': 1700000002000.0, }, ] @@ -76,6 +76,7 @@ def test_to_dict(self) -> None: response_list=RESPONSE_LIST, unread_response_count=0, created_on_msecs=1700000000000.0, + last_updated_msecs=1700000000000.0, ) self.assertEqual(feedback.to_dict(), expected_dict) @@ -84,9 +85,12 @@ def test_to_summary_dict(self) -> None: expected_dict: general_feedback_domain.LessonFeedbackSummaryDict = { 'id': 'feedback_id', 'feedback_text_preview': f'{"N" * 97}...', + 'latest_response_preview': f'{"N" * 97}...', 'status': 'open', 'source': 'lesson', + 'lesson_title': 'exp1', 'unread_response_count': 0, + 'last_updated_msecs': 1700000000000.0, } feedback = general_feedback_domain.LessonFeedback( @@ -99,9 +103,10 @@ def test_to_summary_dict(self) -> None: response_list=RESPONSE_LIST, unread_response_count=0, created_on_msecs=1700000000000.0, + last_updated_msecs=1700000000000.0, ) - self.assertEqual(feedback.to_summary_dict(), expected_dict) + self.assertEqual(feedback.to_summary_dict('exp1'), expected_dict) def test_to_learner_dict(self) -> None: expected_dict: general_feedback_domain.LessonFeedbackDict = { @@ -125,6 +130,7 @@ def test_to_learner_dict(self) -> None: response_list=RESPONSE_LIST, unread_response_count=0, created_on_msecs=1700000000000.0, + last_updated_msecs=1700000000000.0, ) self.assertEqual(feedback.to_learner_dict(), expected_dict) diff --git a/core/domain/general_feedback_services.py b/core/domain/general_feedback_services.py index 5621b556cc7ca..994a7595873bd 100644 --- a/core/domain/general_feedback_services.py +++ b/core/domain/general_feedback_services.py @@ -19,7 +19,7 @@ import urllib.parse from core import feconf, utils -from core.domain import email_manager, general_feedback_domain +from core.domain import email_manager, exp_services, general_feedback_domain from core.platform import models from typing import Dict, List, Optional, Tuple, Union, cast @@ -81,6 +81,7 @@ def _lesson_feedback_model_to_domain( response_list=sanitized_responses, unread_response_count=model.unread_response_count, created_on_msecs=utils.get_time_in_millisecs(model.created_on), + last_updated_msecs=utils.get_time_in_millisecs(model.last_updated), ) @@ -320,30 +321,52 @@ def get_lesson_feedback_summaries( if date_to_msecs is not None else None ) + status_filters = [status_filter] if status_filter else None model_list, next_cursor, more = ( general_feedback_models.LessonFeedbackModel.fetch_page( page_size=20, cursor=cursor, exploration_id=exploration_id, - status_filter=status_filter, + status_filter=status_filters, date_from=date_from, date_to=date_to, ) ) # Here we use cast because PlatformFeedbackModel.fetch_page() inherits its # return annotation from BaseFeedbackModel.fetch_page(). - summaries = [ + domain_objects = [ _lesson_feedback_model_to_domain( cast(general_feedback_models.LessonFeedbackModel, model) - ).to_summary_dict() + ) for model in model_list ] + + # Batch-fetch exploration titles once per page rather than once per + # feedback entry, to avoid an N+1 query pattern. + exp_ids = list( + { + domain_object.lesson_metadata['exploration_id'] + for domain_object in domain_objects + } + ) + exp_titles_and_categories = ( + exp_services.get_exploration_titles_and_categories(exp_ids) + ) + + summaries = [ + domain_object.to_summary_dict( + exp_titles_and_categories.get( + domain_object.lesson_metadata['exploration_id'], {} + ).get('title'), + ) + for domain_object in domain_objects + ] return summaries, next_cursor, more def get_learner_feedback_summaries( author_id: str, - status_filter: Optional[str] = None, + status_filter: Optional[List[str]] = None, cursor: Optional[str] = None, date_from_msecs: Optional[float] = None, date_to_msecs: Optional[float] = None, @@ -356,8 +379,8 @@ def get_learner_feedback_summaries( Args: author_id: str. The learner user ID. - status_filter: Optional[str]. If provided, only return feedback with - this status. + status_filter: Optional[List[str]]. If provided, only return feedback with + these status. cursor: Optional[str]. Pagination cursor from a previous response. date_from_msecs: Optional[float]. If provided, only return feedback created after this time. @@ -389,16 +412,38 @@ def get_learner_feedback_summaries( status_filter=status_filter, date_from=date_from, date_to=date_to, + order_by_last_updated=True, ) ) # Here we use cast because LessonFeedbackModel.fetch_page() inherits its # return annotation from BaseFeedbackModel.fetch_page(). - summaries = [ + domain_objects = [ _lesson_feedback_model_to_domain( cast(general_feedback_models.LessonFeedbackModel, model) - ).to_summary_dict() + ) for model in model_list ] + + # Batch-fetch exploration titles once per page rather than once per + # feedback entry, to avoid an N+1 query pattern. + exp_ids = list( + { + domain_object.lesson_metadata['exploration_id'] + for domain_object in domain_objects + } + ) + exp_titles_and_categories = ( + exp_services.get_exploration_titles_and_categories(exp_ids) + ) + + summaries = [ + domain_object.to_summary_dict( + exp_titles_and_categories.get( + domain_object.lesson_metadata['exploration_id'], {} + ).get('title'), + ) + for domain_object in domain_objects + ] return summaries, next_cursor, more @@ -420,13 +465,31 @@ def get_learner_feedback( ) if model is None or model.author_id != author_id: return None - - model.unread_response_count = 0 - model.update_timestamps() - model.put() + if model.unread_response_count > 0: + model.unread_response_count = 0 + model.update_timestamps(update_last_updated_time=False) + model.put() return _lesson_feedback_model_to_domain(model) +def get_learner_unread_feedback_count(author_id: str) -> int: + """Returns the learner's global unread feedback response count. + + The count spans every non-deleted lesson feedback entry authored by the + learner, regardless of status or list pagination, so it can be used as a + stable total for the My Suggestions tab indicator. + + Args: + author_id: str. The learner user ID. + + Returns: + int. The total number of unread creator responses. + """ + return general_feedback_models.LessonFeedbackModel.get_total_unread_response_count( + author_id=author_id + ) + + def _append_lesson_feedback_model_response( model: general_feedback_models.LessonFeedbackModel, response_text: str, @@ -490,17 +553,22 @@ def update_lesson_feedback( raise ValueError('Invalid exploration ID: %s' % exp_id) old_status = model.status model.status = new_status + status_changed_to_fixed = ( + old_status != new_status and new_status == feconf.STATUS_CHOICES_FIXED + ) if reply_text is not None: _append_lesson_feedback_model_response( model=model, response_text=reply_text, responder_id=responder_id, ) + elif status_changed_to_fixed: + model.unread_response_count += 1 model.update_timestamps() model.put() feedback = _lesson_feedback_model_to_domain(model) - if old_status != new_status: + if status_changed_to_fixed: email_manager.send_feedback_status_change_email( feedback, author_id=model.author_id, @@ -701,13 +769,14 @@ def get_platform_feedback_summaries( if date_to_msecs is not None else None ) + status_filters = [status_filter] if status_filter else None model_list, next_cursor, more = ( general_feedback_models.PlatformFeedbackModel.fetch_page( page_size=20, cursor=cursor, destination_dashboard=dashboard_filter, exploration_id=exploration_id, - status_filter=status_filter, + status_filter=status_filters, date_from=date_from, date_to=date_to, ) @@ -723,6 +792,41 @@ def get_platform_feedback_summaries( return summaries, next_cursor, more +def get_feedback_and_report_status_counts( + exp_id: str, +) -> general_feedback_domain.FeedbackStatusCountsDict: + """Returns per-status counts of lesson feedback and platform reports. + + Counts every non-deleted LessonFeedbackModel and PlatformFeedbackModel + entry that belongs to the given exploration, grouped by moderation + status. + + Args: + exp_id: str. The exploration id to retrieve counts for. + + Returns: + FeedbackStatusCountsDict. A dict with two keys: + lesson_feedback_counts: dict mapping each status choice to the + number of lesson feedback entries with that status, plus a + 'total' key. + platform_report_counts: dict mapping each status choice to the + number of issue reports with that status, plus a 'total' + key. + """ + return { + 'lesson_feedback_counts': ( + general_feedback_models.LessonFeedbackModel.get_status_counts( + exploration_id=exp_id + ) + ), + 'platform_report_counts': ( + general_feedback_models.PlatformFeedbackModel.get_status_counts( + exploration_id=exp_id + ) + ), + } + + def _update_platform_feedback_model_status( model: general_feedback_models.PlatformFeedbackModel, new_status: str, diff --git a/core/domain/general_feedback_services_test.py b/core/domain/general_feedback_services_test.py index fb3f871daa0a3..7e26f1e8b53a8 100644 --- a/core/domain/general_feedback_services_test.py +++ b/core/domain/general_feedback_services_test.py @@ -185,7 +185,7 @@ def test_get_lesson_feedback_summaries_applies_filters(self) -> None: ) self.assertEqual( mock_fetch_page.call_args.kwargs['status_filter'], - feconf.STATUS_CHOICES_OPEN, + [feconf.STATUS_CHOICES_OPEN], ) self.assertIsNotNone(mock_fetch_page.call_args.kwargs['date_from']) self.assertIsNotNone(mock_fetch_page.call_args.kwargs['date_to']) @@ -281,7 +281,7 @@ def test_update_lesson_feedback_updates_status_without_reply(self) -> None: assert updated_feedback is not None self.assertEqual(updated_feedback.status, feconf.STATUS_CHOICES_FIXED) self.assertEqual(updated_feedback.response_list, []) - self.assertEqual(updated_feedback.unread_response_count, 0) + self.assertEqual(updated_feedback.unread_response_count, 1) def test_update_lesson_feedback_returns_none_for_missing_feedback( self, diff --git a/core/domain/rte_component_registry_test.py b/core/domain/rte_component_registry_test.py index 808d0adacdc35..f4c77ab714d7a 100644 --- a/core/domain/rte_component_registry_test.py +++ b/core/domain/rte_component_registry_test.py @@ -180,21 +180,17 @@ def test_rte_components_are_valid(self) -> None: self.assertTrue(os.path.isdir(component_dir)) # In this directory there should be a /directives directory, an - # an icon .png file, webdriverio.js file, - # and an optional preview .png file. + # icon .png file, and an optional preview .png file. # In /directives directory should be HTML file, a JS file, # there could be multiple JS and HTML files. dir_contents = self._listdir_omit_ignored(component_dir) - self.assertLessEqual(len(dir_contents), 5) + self.assertLessEqual(len(dir_contents), 4) directives_dir = os.path.join(component_dir, 'directives') png_file = os.path.join(component_dir, '%s.png' % component_id) - webdriverio_file = os.path.join(component_dir, 'webdriverio.js') - self.assertTrue(os.path.isdir(directives_dir)) self.assertTrue(os.path.isfile(png_file)) - self.assertTrue(os.path.isfile(webdriverio_file)) main_ts_file = os.path.join( directives_dir, diff --git a/core/feconf.py b/core/feconf.py index 2d2e5630beda6..e7587b038d18a 100644 --- a/core/feconf.py +++ b/core/feconf.py @@ -987,6 +987,7 @@ def get_default_email_subject(exp_title: str) -> str: EXPLORATION_URL_EMBED_PREFIX = '/embed/exploration' FEATURE_FLAGS_URL = '/feature_flags' FEEDBACK_STATS_URL_PREFIX = '/feedbackstatshandler' +FEEDBACK_STATUS_COUNTS_URL = '/feedbackstatuscounts' FEEDBACK_THREAD_URL_PREFIX = '/threadhandler' FEEDBACK_THREADLIST_URL_PREFIX = '/threadlisthandler' FEEDBACK_THREADLIST_URL_PREFIX_FOR_TOPICS = '/threadlisthandlerfortopic' @@ -1032,6 +1033,7 @@ def get_default_email_subject(exp_title: str) -> str: MACHINE_TRANSLATION_DATA_URL = '/machine_translated_state_texts_handler' MERGE_SKILLS_URL = '/merge_skills_handler' METADATA_VERSION_HISTORY_URL_PREFIX = '/version_history_handler/metadata' +MY_FEEDBACK_UNREAD_COUNT_URL = '/my_feedback/unread_count' MY_FEEDBACK_URL = '/my_feedback' NEW_COLLECTION_URL = '/collection_editor_handler/create_new' NEW_EXPLORATION_URL = '/contributehandler/create_new' diff --git a/core/storage/base_model/gae_models.py b/core/storage/base_model/gae_models.py index 8a3e8678accbd..1a007325ec011 100644 --- a/core/storage/base_model/gae_models.py +++ b/core/storage/base_model/gae_models.py @@ -2225,7 +2225,7 @@ def _generate_new_id(cls) -> str: def _get_filtered_query( cls, author_id: Optional[str] = None, - status_filter: Optional[str] = 'open', + status_filter: Optional[List[str]] = None, exploration_id: Optional[str] = None, date_from: Optional[datetime.datetime] = None, date_to: Optional[datetime.datetime] = None, @@ -2234,7 +2234,8 @@ def _get_filtered_query( Args: author_id: Optional[str]. If provided, filters by author ID. - status_filter: Optional[str]. If provided, filters by status. + status_filter: Optional[List[str]]. If provided, filters by these + status values. exploration_id: Optional[str]. If provided, filters by exploration ID. date_from: Optional[datetime.datetime]. If provided, filters for @@ -2244,6 +2245,10 @@ def _get_filtered_query( Returns: Query. The filtered query object. + + Raises: + Exception. Raised when any of the given status filter values is + not present in feconf.STATUS_CHOICES. """ query = cls.query() # Ignoring deleted threads as they are not relevant for operations. @@ -2251,8 +2256,19 @@ def _get_filtered_query( if author_id is not None: query = query.filter(cls.author_id == author_id) - if status_filter and status_filter in feconf.STATUS_CHOICES: - query = query.filter(cls.status == status_filter) + if status_filter: + invalid_statuses = [ + status + for status in status_filter + if status not in feconf.STATUS_CHOICES + ] + if invalid_statuses: + raise Exception( + 'Invalid status filter values: %s. Expected statuses to ' + 'be chosen from feconf.STATUS_CHOICES: %s.' + % (invalid_statuses, feconf.STATUS_CHOICES) + ) + query = query.filter(cls.status.IN(status_filter)) if exploration_id is not None: query = query.filter(cls.exploration_id == exploration_id) if date_from is not None: @@ -2262,6 +2278,35 @@ def _get_filtered_query( return query + @classmethod + def get_status_counts( + cls, + exploration_id: Optional[str] = None, + ) -> Dict[str, int]: + """Returns the number of non-deleted feedback entries per status. + + Args: + exploration_id: Optional[str]. If provided, only entries belonging + to this exploration are counted. + + Returns: + dict. Maps each status in feconf.STATUS_CHOICES to its count, + plus a 'total' key with the overall number of counted entries. + """ + query = cls._get_filtered_query(exploration_id=exploration_id) + counts: Dict[str, int] = {status: 0 for status in feconf.STATUS_CHOICES} + total = 0 + # A projection query is used to avoid fetching full entities, since + # only the status field is needed for counting. + status_models: Sequence[BaseFeedbackModel] = query.fetch( + projection=[cls.status] + ) + for model in status_models: + counts[model.status] += 1 + total += 1 + counts['total'] = total + return counts + @classmethod # Here we use type Any because subclasses can extend the filtered query with # model-specific keyword filters. @@ -2270,13 +2315,63 @@ def fetch_page( page_size: int, cursor: Optional[str] = None, author_id: Optional[str] = None, - status_filter: Optional[str] = None, + status_filter: Optional[List[str]] = None, exploration_id: Optional[str] = None, date_from: Optional[datetime.datetime] = None, date_to: Optional[datetime.datetime] = None, + order_by_last_updated: bool = False, **kwargs: Any, ) -> tuple[Sequence['BaseFeedbackModel'], Optional[str], bool]: - """Fetches a page of feedback entries sorted by created_on_desc.""" + """Fetches a page of feedback entries sorted by created_on_desc or + last_updated. + + When multiple status filters are provided, uses offset-based pagination + with .fetch() instead of .fetch_page() to avoid the IN operator + incompatibility with Datastore cursors. + """ + next_cursor_str: Optional[str] = None + + # When multiple statuses are provided, use offset-based pagination + # to avoid the IN + fetch_page incompatibility. This follows the + # pattern used by suggestion models in this codebase. + if status_filter and len(status_filter) > 1: + sort_key = ( + cls.last_updated if order_by_last_updated else cls.created_on + ) + query = cls._get_filtered_query( + author_id=author_id, + status_filter=status_filter, + exploration_id=exploration_id, + date_from=date_from, + date_to=date_to, + **kwargs, + ).order(-sort_key) + + # Decode cursor to get offset. + offset = 0 + if cursor: + try: + offset = int(cursor) + except (ValueError, TypeError): + offset = 0 + + results: Sequence[BaseFeedbackModel] = query.fetch( + page_size, offset=offset + ) + next_offset = offset + len(results) + + # Check if there are more results. Only one entity is fetched + # since the result is used solely to compute the 'more' flag. + next_results: Sequence[BaseFeedbackModel] = query.fetch( + 1, offset=next_offset + ) + more = len(next_results) > 0 + + if more: + next_cursor_str = str(next_offset) + + return results, next_cursor_str, more + query = cls._get_filtered_query( author_id=author_id, status_filter=status_filter, @@ -2284,15 +2379,19 @@ def fetch_page( date_from=date_from, date_to=date_to, **kwargs, - ).order(-cls.created_on) + ) + + if order_by_last_updated: + query = query.order(-cls.last_updated) + else: + query = query.order(-cls.created_on) start_cursor = datastore_services.make_cursor(urlsafe_cursor=cursor) - results: Sequence[BaseFeedbackModel] results, next_cursor, more = query.fetch_page( page_size, start_cursor=start_cursor ) - next_cursor_str: Optional[str] = None + next_cursor_str = None if len(results) < page_size: more = False elif next_cursor and more: diff --git a/core/storage/base_model/gae_models_test.py b/core/storage/base_model/gae_models_test.py index 40609adbd7139..a14be9d9bde67 100644 --- a/core/storage/base_model/gae_models_test.py +++ b/core/storage/base_model/gae_models_test.py @@ -1167,13 +1167,14 @@ class BaseFeedbackModelTests(test_utils.GenericTestBase): def _create_feedback_model( self, model_id: str, + status: str = feconf.STATUS_CHOICES_OPEN, ) -> None: """Creates a TestBaseFeedbackModel with the given creation time.""" model = TestBaseFeedbackModel( id=model_id, author_id='user_id', feedback_text='Feedback text', - status=feconf.STATUS_CHOICES_OPEN, + status=status, exploration_id='exp_id', lesson_metadata_schema_version=1, lesson_metadata={}, @@ -1289,3 +1290,226 @@ def test_fetch_page_accepts_cursor_for_later_page( ) self.assertIsNone(final_cursor) self.assertFalse(more) + + def test_fetch_page_with_single_status_filter_paginates_by_cursor( + self, + ) -> None: + self._create_feedback_model('feedback_1') + self._create_feedback_model( + 'feedback_2', status=feconf.STATUS_CHOICES_FIXED + ) + self._create_feedback_model('feedback_3') + + first_page, next_cursor, more = TestBaseFeedbackModel.fetch_page( + page_size=1, status_filter=[feconf.STATUS_CHOICES_OPEN] + ) + assert next_cursor is not None + + self.assertEqual( + [feedback_model.id for feedback_model in first_page], + ['feedback_3'], + ) + self.assertTrue(more) + + final_page, final_cursor, more = TestBaseFeedbackModel.fetch_page( + page_size=1, + status_filter=[feconf.STATUS_CHOICES_OPEN], + cursor=next_cursor, + ) + + self.assertEqual( + [feedback_model.id for feedback_model in final_page], + ['feedback_1'], + ) + self.assertIsNone(final_cursor) + self.assertFalse(more) + + def test_fetch_page_with_multiple_status_filters_returns_matches( + self, + ) -> None: + self._create_feedback_model('feedback_1') + self._create_feedback_model( + 'feedback_2', status=feconf.STATUS_CHOICES_FIXED + ) + self._create_feedback_model( + 'feedback_3', status=feconf.STATUS_CHOICES_COMPLIMENT + ) + + feedback_models, next_cursor, more = TestBaseFeedbackModel.fetch_page( + page_size=10, + status_filter=[ + feconf.STATUS_CHOICES_OPEN, + feconf.STATUS_CHOICES_FIXED, + ], + ) + + self.assertEqual( + [feedback_model.id for feedback_model in feedback_models], + ['feedback_2', 'feedback_1'], + ) + self.assertIsNone(next_cursor) + self.assertFalse(more) + + def test_fetch_page_with_multiple_status_filters_paginates_by_offset( + self, + ) -> None: + self._create_feedback_model('feedback_1') + self._create_feedback_model('feedback_2') + self._create_feedback_model('feedback_3') + + first_page, next_cursor, more = TestBaseFeedbackModel.fetch_page( + page_size=1, + status_filter=[ + feconf.STATUS_CHOICES_OPEN, + feconf.STATUS_CHOICES_FIXED, + ], + ) + assert next_cursor is not None + + self.assertEqual( + [feedback_model.id for feedback_model in first_page], + ['feedback_3'], + ) + self.assertTrue(more) + + second_page, next_cursor, more = TestBaseFeedbackModel.fetch_page( + page_size=1, + status_filter=[ + feconf.STATUS_CHOICES_OPEN, + feconf.STATUS_CHOICES_FIXED, + ], + cursor=next_cursor, + ) + assert next_cursor is not None + + self.assertEqual( + [feedback_model.id for feedback_model in second_page], + ['feedback_2'], + ) + self.assertTrue(more) + + final_page, final_cursor, more = TestBaseFeedbackModel.fetch_page( + page_size=1, + status_filter=[ + feconf.STATUS_CHOICES_OPEN, + feconf.STATUS_CHOICES_FIXED, + ], + cursor=next_cursor, + ) + + self.assertEqual( + [feedback_model.id for feedback_model in final_page], + ['feedback_1'], + ) + self.assertIsNone(final_cursor) + self.assertFalse(more) + + def test_fetch_page_with_invalid_status_filter_raises_error(self) -> None: + self._create_feedback_model('feedback_1') + + with self.assertRaisesRegex( + Exception, + r'Invalid status filter values: \[\'invalid_status\'\]\. ' + 'Expected statuses to be chosen from feconf.STATUS_CHOICES', + ): + TestBaseFeedbackModel.fetch_page( + page_size=10, + status_filter=['open', 'invalid_status'], + ) + + with self.assertRaisesRegex( + Exception, + r'Invalid status filter values: \[\'not_a_real_status\'\]\. ' + 'Expected statuses to be chosen from feconf.STATUS_CHOICES', + ): + TestBaseFeedbackModel.fetch_page( + page_size=10, + status_filter=['not_a_real_status'], + ) + + def test_fetch_page_with_multiple_statuses_treats_invalid_cursor_as_zero_offset( + self, + ) -> None: + self._create_feedback_model('feedback_1') + self._create_feedback_model('feedback_2') + + feedback_models, next_cursor, more = TestBaseFeedbackModel.fetch_page( + page_size=1, + status_filter=[ + feconf.STATUS_CHOICES_OPEN, + feconf.STATUS_CHOICES_FIXED, + ], + cursor='not-a-number', + ) + + self.assertEqual( + [feedback_model.id for feedback_model in feedback_models], + ['feedback_2'], + ) + self.assertTrue(more) + self.assertIsNotNone(next_cursor) + + def test_fetch_page_with_multiple_statuses_reports_no_more_on_full_final_page( + self, + ) -> None: + self._create_feedback_model('feedback_1') + self._create_feedback_model('feedback_2') + + feedback_models, next_cursor, more = TestBaseFeedbackModel.fetch_page( + page_size=2, + status_filter=[ + feconf.STATUS_CHOICES_OPEN, + feconf.STATUS_CHOICES_FIXED, + ], + ) + + self.assertEqual(len(feedback_models), 2) + self.assertIsNone(next_cursor) + self.assertFalse(more) + + def test_fetch_page_with_multiple_statuses_orders_by_last_updated( + self, + ) -> None: + self._create_feedback_model( + 'feedback_1', status=feconf.STATUS_CHOICES_FIXED + ) + self._create_feedback_model('feedback_2') + # Re-saving feedback_1 makes it the most recently updated entry. + resaved_model = TestBaseFeedbackModel.get_by_id('feedback_1') + assert resaved_model is not None + resaved_model.update_timestamps() + resaved_model.put() + + feedback_models, _, _ = TestBaseFeedbackModel.fetch_page( + page_size=10, + status_filter=[ + feconf.STATUS_CHOICES_OPEN, + feconf.STATUS_CHOICES_FIXED, + ], + order_by_last_updated=True, + ) + + self.assertEqual( + [feedback_model.id for feedback_model in feedback_models], + ['feedback_1', 'feedback_2'], + ) + + def test_fetch_page_with_single_status_orders_by_last_updated( + self, + ) -> None: + self._create_feedback_model('feedback_1') + self._create_feedback_model('feedback_2') + # Re-saving feedback_1 makes it the most recently updated entry. + resaved_model = TestBaseFeedbackModel.get_by_id('feedback_1') + assert resaved_model is not None + resaved_model.update_timestamps() + resaved_model.put() + + feedback_models, _, _ = TestBaseFeedbackModel.fetch_page( + page_size=10, order_by_last_updated=True + ) + + self.assertEqual( + [feedback_model.id for feedback_model in feedback_models], + ['feedback_1', 'feedback_2'], + ) diff --git a/core/storage/general_feedback/gae_models.py b/core/storage/general_feedback/gae_models.py index 17d1dfac888c4..b6372b9a80de9 100644 --- a/core/storage/general_feedback/gae_models.py +++ b/core/storage/general_feedback/gae_models.py @@ -241,6 +241,34 @@ def create( feedback_model.put() return feedback_id + @classmethod + def get_total_unread_response_count(cls, author_id: str) -> int: + """Returns the learner's total number of unread creator responses. + + The count spans every non-deleted lesson feedback entry authored by + the learner, so it is independent of list pagination. + + Args: + author_id: str. The learner user ID. + + Returns: + int. Sum of unread_response_count across the learner's entries. + """ + # unread_response_count is not an indexed property, so the learner's + # entries are fetched and summed in memory. The volume per learner is + # small since it only includes their own submissions. + feedback_models: Sequence[LessonFeedbackModel] = ( + cls.get_all() + .filter(cls.deleted.IN([False])) + .filter(cls.author_id == author_id) + .fetch() + ) + return sum( + model.unread_response_count + for model in feedback_models + if model.unread_response_count > 0 + ) + class PlatformFeedbackModel(base_models.BaseFeedbackModel): """Primary datastore model for lesson issue reports and site issue reports. @@ -402,7 +430,7 @@ def _validate_create_args( def _get_filtered_query( cls, author_id: Optional[str] = None, - status_filter: Optional[str] = 'open', + status_filter: Optional[List[str]] = None, exploration_id: Optional[str] = None, date_from: Optional[datetime.datetime] = None, date_to: Optional[datetime.datetime] = None, @@ -415,7 +443,8 @@ def _get_filtered_query( Args: author_id: Optional[str]. If provided, filters by author ID. - status_filter: Optional[str]. If provided, filters by status. + status_filter: Optional[List[str]]. If provided, filters by these + status values. exploration_id: Optional[str]. If provided, filters by exploration ID. date_from: Optional[datetime]. If provided, filters reports created diff --git a/core/templates/app.constants.ts b/core/templates/app.constants.ts index a65a4b8334366..02370f4d8c5c2 100644 --- a/core/templates/app.constants.ts +++ b/core/templates/app.constants.ts @@ -220,6 +220,7 @@ export const AppConstants = { CONTRIBUTION_STATS_SUBTYPE_SUBMISSION: 'submission', CONTRIBUTION_STATS_SUBTYPE_REVIEW: 'review', CONTRIBUTION_STATS_SUBTYPE_CORRECTION: 'correction', + CONTRIBUTION_STATS_SUBTYPE_COORDINATE: 'coordinate', CONTRIBUTION_STATS_TYPES: { TRANSLATION_CONTRIBUTION: { NAME: 'translationContribution', diff --git a/core/templates/base-components/base-content.component.spec.ts b/core/templates/base-components/base-content.component.spec.ts index cbdea664d633f..7dbb5ee289b15 100644 --- a/core/templates/base-components/base-content.component.spec.ts +++ b/core/templates/base-components/base-content.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Tests for the Base Transclusion Component. */ +// @ts-nocheck + import {DOCUMENT} from '@angular/common'; import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; diff --git a/core/templates/base-components/feedback-modal.component.html b/core/templates/base-components/feedback-modal.component.html index d0fe987145c9e..998120c307c8d 100644 --- a/core/templates/base-components/feedback-modal.component.html +++ b/core/templates/base-components/feedback-modal.component.html @@ -181,7 +181,10 @@

{{ title | translate }}

| translate }} - @@ -122,7 +132,7 @@

-
diff --git a/core/templates/pages/contributor-dashboard-admin-page/contributor-admin-dashboard-page.component.spec.ts b/core/templates/pages/contributor-dashboard-admin-page/contributor-admin-dashboard-page.component.spec.ts index 457b3b76d31fd..46385554fb959 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/contributor-admin-dashboard-page.component.spec.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/contributor-admin-dashboard-page.component.spec.ts @@ -858,6 +858,26 @@ describe('Contributor dashboard Admin page', () => { expect(openRoleEditorSpy).toHaveBeenCalledWith('user1'); })); + it('should add coordinator tabs to the contribution types', fakeAsync(() => { + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + expect(component.CONTRIBUTION_TYPES).toContain( + component.TAB_NAME_TRANSLATION_COORDINATOR + ); + expect(component.CONTRIBUTION_TYPES).toContain( + component.TAB_NAME_QUESTION_COORDINATOR + ); + })); + + it('should identify coordinator tabs', () => { + component.setActiveTab(component.TAB_NAME_TRANSLATION_COORDINATOR); + expect(component.isCoordinatorTab()).toBeTrue(); + component.setActiveTab(component.TAB_NAME_QUESTION_SUBMITTER); + expect(component.isCoordinatorTab()).toBeFalse(); + }); + it('should start any language with its English name', fakeAsync(() => { component.ngOnInit(); tick(); diff --git a/core/templates/pages/contributor-dashboard-admin-page/contributor-admin-dashboard-page.component.ts b/core/templates/pages/contributor-dashboard-admin-page/contributor-admin-dashboard-page.component.ts index 83b3170c55c9f..9bdb09a18d27f 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/contributor-admin-dashboard-page.component.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/contributor-admin-dashboard-page.component.ts @@ -100,6 +100,8 @@ export class ContributorAdminDashboardPageComponent implements OnInit { TAB_NAME_TRANSLATION_REVIEWER: string = 'Translation Reviewer'; TAB_NAME_QUESTION_SUBMITTER: string = 'Question Submitter'; TAB_NAME_QUESTION_REVIEWER: string = 'Question Reviewer'; + TAB_NAME_TRANSLATION_COORDINATOR: string = 'Translation Coordinator'; + TAB_NAME_QUESTION_COORDINATOR: string = 'Question Coordinator'; ONE_DAY_IN_MILLIS: number = 24 * 60 * 60 * 1000; translationReviewersCountByLanguage!: translationReviewersCount; translationReviewersCount: number = 0; @@ -171,7 +173,8 @@ export class ContributorAdminDashboardPageComponent implements OnInit { if (this.isTranslationCoordinator) { this.CONTRIBUTION_TYPES.push( this.TAB_NAME_TRANSLATION_SUBMITTER, - this.TAB_NAME_TRANSLATION_REVIEWER + this.TAB_NAME_TRANSLATION_REVIEWER, + this.TAB_NAME_TRANSLATION_COORDINATOR ); this.contributorDashboardAdminStatsBackendApiService @@ -193,7 +196,8 @@ export class ContributorAdminDashboardPageComponent implements OnInit { if (this.isQuestionCoordinator) { this.CONTRIBUTION_TYPES.push( this.TAB_NAME_QUESTION_SUBMITTER, - this.TAB_NAME_QUESTION_REVIEWER + this.TAB_NAME_QUESTION_REVIEWER, + this.TAB_NAME_QUESTION_COORDINATOR ); } @@ -236,6 +240,13 @@ export class ContributorAdminDashboardPageComponent implements OnInit { ); } + isCoordinatorTab(): boolean { + return ( + this.activeTab === this.TAB_NAME_TRANSLATION_COORDINATOR || + this.activeTab === this.TAB_NAME_QUESTION_COORDINATOR + ); + } + filterTopicChoices(topic: TopicChoice[]): TopicChoice[] { let filteredTopic: TopicChoice[] = []; diff --git a/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-admin-page.component.spec.ts b/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-admin-page.component.spec.ts index 51c41f14f5170..81fa05854030e 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-admin-page.component.spec.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-admin-page.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the Contributor dashboard admin page. */ +// @ts-nocheck + import { ComponentFixture, TestBed, diff --git a/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-admin-summary.model.spec.ts b/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-admin-summary.model.spec.ts index 33f21d7620e56..d7356674041a8 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-admin-summary.model.spec.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-admin-summary.model.spec.ts @@ -21,7 +21,7 @@ import { QuestionSubmitterStats, TranslationReviewerStats, TranslationSubmitterStats, -} from './contributor-dashboard-admin-summary.model'; +} from '../../domain/contributor_dashboard/contributor-dashboard-admin-summary.model'; describe('Translation Submitter Stats Model', () => { it('should correctly convert backend dict to domain object.', () => { diff --git a/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-tables/contributor-admin-stats-table.component.spec.ts b/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-tables/contributor-admin-stats-table.component.spec.ts index 39496dde3f695..41fe1e056a429 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-tables/contributor-admin-stats-table.component.spec.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-tables/contributor-admin-stats-table.component.spec.ts @@ -29,6 +29,7 @@ import {HttpClientTestingModule} from '@angular/common/http/testing'; import {ContributorAdminStatsTable} from './contributor-admin-stats-table.component'; import { ContributorDashboardAdminStatsBackendApiService, + QuestionCoordinatorStatsData, QuestionReviewerStatsData, QuestionSubmitterStatsData, TranslationReviewerStatsData, @@ -42,11 +43,14 @@ import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; import {CdAdminQuestionRoleEditorModal} from '../question-role-editor-modal/cd-admin-question-role-editor-modal.component'; import {BrowserDynamicTestingModule} from '@angular/platform-browser-dynamic/testing'; import { + QuestionCoordinatorStats, QuestionReviewerStats, QuestionSubmitterStats, + TranslationCoordinatorStats, TranslationReviewerStats, TranslationSubmitterStats, -} from '../contributor-dashboard-admin-summary.model'; +} from '../../../domain/contributor_dashboard/contributor-dashboard-admin-summary.model'; +import {AppConstants} from 'app.constants'; describe('Contributor stats component', () => { const createQuestionReviewerStats = function createQuestionReviewerStats( @@ -907,6 +911,150 @@ describe('Contributor stats component', () => { component.onItemsPerPageChange('05'); expect(component.itemsPerPage).toEqual(5); })); + + it('should return the translation coordinator stats attributes correctly', fakeAsync(() => { + const translationCoordinatorStats = new TranslationCoordinatorStats( + 'user1', + 'en', + 5, + 3, + 10 + ); + expect( + component.getFormattedContributorAttributes(translationCoordinatorStats) + ).toEqual([ + {key: 'Translators', displayText: '5'}, + {key: 'Reviewers', displayText: '3'}, + ]); + })); + + it('should return no attributes for question coordinators', fakeAsync(() => { + const questionCoordinatorStats = new QuestionCoordinatorStats('user1', 5); + expect( + component.getFormattedContributorAttributes(questionCoordinatorStats) + ).toEqual([]); + })); + }); + + describe('when user navigates to coordinator tabs', () => { + beforeEach(waitForAsync(() => { + spyOnProperty($window.nativeWindow, 'innerWidth').and.returnValue(900); + })); + + it('should append the chevron column on mobile', () => { + spyOn(component, 'isMobileView').and.returnValue(true); + component.updateColumns( + AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE + ); + expect(component.columnsToDisplay).toEqual([ + 'contributorName', + 'lastContributedInDays', + 'chevron', + ]); + }); + + it('should map coordinator tabs to the coordinate subtype', () => { + expect( + component.getContributionType( + component.TAB_NAME_TRANSLATION_COORDINATOR + ) + ).toEqual(AppConstants.CONTRIBUTION_STATS_TYPE_TRANSLATION); + expect( + component.getContributionType(component.TAB_NAME_QUESTION_COORDINATOR) + ).toEqual(AppConstants.CONTRIBUTION_STATS_TYPE_QUESTION); + expect( + component.getContributionSubType( + component.TAB_NAME_TRANSLATION_COORDINATOR + ) + ).toEqual(AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE); + expect( + component.getContributionSubType( + component.TAB_NAME_QUESTION_COORDINATOR + ) + ).toEqual(AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE); + }); + + it('should label the last-activity column as "Last Active"', () => { + expect( + component.getLastContributedType( + component.TAB_NAME_TRANSLATION_COORDINATOR + ) + ).toEqual('Last Active'); + expect( + component.getLastContributedType( + component.TAB_NAME_QUESTION_COORDINATOR + ) + ).toEqual('Last Active'); + }); + + it('should only display name and last-active columns', () => { + component.updateColumns( + AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE + ); + expect(component.columnsToDisplay).toEqual([ + 'chevron', + 'contributorName', + 'lastContributedInDays', + ]); + }); + + it('should show question coordinator stats', fakeAsync(() => { + spyOn( + contributorDashboardAdminStatsBackendApiService, + 'fetchContributorAdminStats' + ).and.returnValue( + Promise.resolve({ + stats: [new QuestionCoordinatorStats('user1', 3)], + nextOffset: 0, + more: false, + } as QuestionCoordinatorStatsData) + ); + const changes: SimpleChanges = { + activeTab: { + currentValue: component.TAB_NAME_QUESTION_COORDINATOR, + previousValue: component.TAB_NAME_QUESTION_REVIEWER, + firstChange: false, + isFirstChange: () => false, + }, + }; + component.inputs.activeTab = component.TAB_NAME_QUESTION_COORDINATOR; + component.ngOnChanges(changes); + component.displayContributorAdminStats(); + tick(); + expect(component.allStats.length).toEqual(1); + expect(component.columnsToDisplay).toEqual([ + 'chevron', + 'contributorName', + 'lastContributedInDays', + ]); + expect(component.noDataMessage).toEqual(''); + })); + + it('should show empty-state message when no coordinator stats', fakeAsync(() => { + spyOn( + contributorDashboardAdminStatsBackendApiService, + 'fetchContributorAdminStats' + ).and.returnValue( + Promise.resolve({ + stats: [], + nextOffset: 0, + more: false, + } as QuestionCoordinatorStatsData) + ); + const changes: SimpleChanges = { + activeTab: { + currentValue: component.TAB_NAME_QUESTION_COORDINATOR, + previousValue: component.TAB_NAME_QUESTION_REVIEWER, + firstChange: false, + isFirstChange: () => false, + }, + }; + component.inputs.activeTab = component.TAB_NAME_QUESTION_COORDINATOR; + component.ngOnChanges(changes); + component.displayContributorAdminStats(); + tick(); + expect(component.noDataMessage).toEqual('No statistics to display'); + })); }); describe('when more than one page of stats are returned', () => { diff --git a/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-tables/contributor-admin-stats-table.component.ts b/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-tables/contributor-admin-stats-table.component.ts index df8cb29a40bc4..99d5a966ef951 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-tables/contributor-admin-stats-table.component.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/contributor-dashboard-tables/contributor-admin-stats-table.component.ts @@ -26,7 +26,7 @@ import {ContributorAttribute} from '../services/format-contributor-attributes.se import {ContributorAdminDashboardFilter} from '../contributor-admin-dashboard-filter.model'; import {AppConstants} from 'app.constants'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; -import {ContributorStats} from '../contributor-dashboard-admin-summary.model'; +import {ContributorStats} from '../../../domain/contributor_dashboard/contributor-dashboard-admin-summary.model'; import {CdAdminQuestionRoleEditorModal} from '../question-role-editor-modal/cd-admin-question-role-editor-modal.component'; import {CdAdminTranslationRoleEditorModal} from '../translation-role-editor-modal/cd-admin-translation-role-editor-modal.component'; import constants from 'assets/constants'; @@ -81,7 +81,7 @@ export class ContributorAdminStatsTable implements OnInit { TAB_NAME_TRANSLATION_REVIEWER: string = 'Translation Reviewer'; TAB_NAME_QUESTION_SUBMITTER: string = 'Question Submitter'; TAB_NAME_QUESTION_REVIEWER: string = 'Question Reviewer'; - TAB_NAME_LANGUAGE_COORDINATOR: string = 'Language Coordinator'; + TAB_NAME_TRANSLATION_COORDINATOR: string = 'Translation Coordinator'; TAB_NAME_QUESTION_COORDINATOR: string = 'Question Coordinator'; loadingMessage!: string; noDataMessage!: string; @@ -126,6 +126,10 @@ export class ContributorAdminStatsTable implements OnInit { case this.TAB_NAME_QUESTION_REVIEWER: lastContributedType = 'Last Reviewed'; break; + case this.TAB_NAME_TRANSLATION_COORDINATOR: + case this.TAB_NAME_QUESTION_COORDINATOR: + lastContributedType = 'Last Active'; + break; } return lastContributedType; @@ -303,10 +307,12 @@ export class ContributorAdminStatsTable implements OnInit { switch (activeTab) { case this.TAB_NAME_TRANSLATION_SUBMITTER: case this.TAB_NAME_TRANSLATION_REVIEWER: + case this.TAB_NAME_TRANSLATION_COORDINATOR: contributionType = AppConstants.CONTRIBUTION_STATS_TYPE_TRANSLATION; break; case this.TAB_NAME_QUESTION_SUBMITTER: case this.TAB_NAME_QUESTION_REVIEWER: + case this.TAB_NAME_QUESTION_COORDINATOR: contributionType = AppConstants.CONTRIBUTION_STATS_TYPE_QUESTION; break; } @@ -327,6 +333,11 @@ export class ContributorAdminStatsTable implements OnInit { case this.TAB_NAME_QUESTION_REVIEWER: contributionSubType = AppConstants.CONTRIBUTION_STATS_SUBTYPE_REVIEW; break; + case this.TAB_NAME_TRANSLATION_COORDINATOR: + case this.TAB_NAME_QUESTION_COORDINATOR: + contributionSubType = + AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE; + break; } return contributionSubType; @@ -338,6 +349,15 @@ export class ContributorAdminStatsTable implements OnInit { } else { this.columnsToDisplay = ['chevron', 'contributorName']; } + if ( + contributionSubType === AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE + ) { + this.columnsToDisplay.push('lastContributedInDays'); + if (this.isMobileView()) { + this.columnsToDisplay.push('chevron'); + } + return; + } if ( contributionSubType === AppConstants.CONTRIBUTION_STATS_SUBTYPE_SUBMISSION ) { diff --git a/core/templates/pages/contributor-dashboard-admin-page/services/contributor-dashboard-admin-stats-backend-api.service.spec.ts b/core/templates/pages/contributor-dashboard-admin-page/services/contributor-dashboard-admin-stats-backend-api.service.spec.ts index 03e358d4789b9..57c37ad42b367 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/services/contributor-dashboard-admin-stats-backend-api.service.spec.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/services/contributor-dashboard-admin-stats-backend-api.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for contributor admin dashboard backend service */ +// @ts-nocheck + import {fakeAsync, flushMicrotasks, TestBed} from '@angular/core/testing'; import { HttpClientTestingModule, @@ -357,6 +359,58 @@ describe('Contribution Admin dashboard stats service', () => { }) ); + it('should reject when translation coordinator stats request fails', fakeAsync(() => { + const url = + '/contributor-dashboard-admin-stats/translation/coordinate' + + '?page_size=20&offset=0&language_code=en'; + + cdasbas + .fetchContributorAdminStats( + ContributorAdminDashboardFilter.createDefault(), + 20, + 0, + AppConstants.CONTRIBUTION_STATS_TYPE_TRANSLATION, + AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE + ) + .then(successHandler, failHandler); + const req = http.expectOne(url); + expect(req.request.method).toEqual('GET'); + req.flush( + {error: 'error'}, + {status: 500, statusText: 'Internal Server Error'} + ); + flushMicrotasks(); + + expect(successHandler).not.toHaveBeenCalled(); + expect(failHandler).toHaveBeenCalled(); + })); + + it('should reject when question coordinator stats request fails', fakeAsync(() => { + const url = + '/contributor-dashboard-admin-stats/question/coordinate' + + '?page_size=20&offset=0&language_code=en'; + + cdasbas + .fetchContributorAdminStats( + ContributorAdminDashboardFilter.createDefault(), + 20, + 0, + AppConstants.CONTRIBUTION_STATS_TYPE_QUESTION, + AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE + ) + .then(successHandler, failHandler); + const req = http.expectOne(url); + expect(req.request.method).toEqual('GET'); + req.flush( + {error: 'error'}, + {status: 500, statusText: 'Internal Server Error'} + ); + flushMicrotasks(); + + expect(successHandler).not.toHaveBeenCalled(); + expect(failHandler).toHaveBeenCalled(); + })); + it('should return available question submitter stats', fakeAsync(() => { spyOn(cdasbas, 'fetchContributorAdminStats').and.callThrough(); const url = @@ -598,6 +652,95 @@ describe('Contribution Admin dashboard stats service', () => { expect(failHandler).not.toHaveBeenCalled(); })); + it('should return available translation coordinator stats', fakeAsync(() => { + const url = + '/contributor-dashboard-admin-stats/translation/coordinate' + + '?page_size=20&offset=0&language_code=en'; + + cdasbas + .fetchContributorAdminStats( + ContributorAdminDashboardFilter.createDefault(), + 20, + 0, + AppConstants.CONTRIBUTION_STATS_TYPE_TRANSLATION, + AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE + ) + .then(successHandler, failHandler); + const req = http.expectOne(url); + expect(req.request.method).toEqual('GET'); + req.flush( + { + stats: [ + { + language_id: 'en', + translators_count: 2, + reviewers_count: 1, + coordinator_activity_list: [ + {translation_coordinator: 'user1', last_activity_days: 3}, + {translation_coordinator: 'user2', last_activity_days: 5}, + ], + }, + ], + }, + {status: 200, statusText: 'Success.'} + ); + flushMicrotasks(); + + expect(successHandler).toHaveBeenCalledWith({ + stats: [ + jasmine.objectContaining({ + contributorName: 'user1', + languageCode: 'en', + translatorsCount: 2, + reviewersCount: 1, + lastContributedInDays: 3, + }), + jasmine.objectContaining({ + contributorName: 'user2', + lastContributedInDays: 5, + }), + ], + nextOffset: 0, + more: false, + }); + expect(failHandler).not.toHaveBeenCalled(); + })); + + it('should return available question coordinator stats', fakeAsync(() => { + const url = + '/contributor-dashboard-admin-stats/question/coordinate' + + '?page_size=20&offset=0&language_code=en'; + + cdasbas + .fetchContributorAdminStats( + ContributorAdminDashboardFilter.createDefault(), + 20, + 0, + AppConstants.CONTRIBUTION_STATS_TYPE_QUESTION, + AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE + ) + .then(successHandler, failHandler); + const req = http.expectOne(url); + expect(req.request.method).toEqual('GET'); + req.flush( + {stats: [{question_coordinator: 'user1', last_activity: 5}]}, + {status: 200, statusText: 'Success.'} + ); + flushMicrotasks(); + + expect(successHandler).toHaveBeenCalledWith({ + stats: [ + jasmine.objectContaining({ + contributorName: 'user1', + lastContributedInDays: 5, + }), + ], + nextOffset: 0, + more: false, + }); + expect(failHandler).not.toHaveBeenCalled(); + })); + it('should return empty stats if contribution type is invalid', fakeAsync(() => { cdasbas .fetchContributorAdminStats( diff --git a/core/templates/pages/contributor-dashboard-admin-page/services/contributor-dashboard-admin-stats-backend-api.service.ts b/core/templates/pages/contributor-dashboard-admin-page/services/contributor-dashboard-admin-stats-backend-api.service.ts index 717eae86da1b6..934c694395fec 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/services/contributor-dashboard-admin-stats-backend-api.service.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/services/contributor-dashboard-admin-stats-backend-api.service.ts @@ -25,7 +25,9 @@ import { TranslationReviewerStats, QuestionSubmitterStats, QuestionReviewerStats, -} from '../contributor-dashboard-admin-summary.model'; + TranslationCoordinatorStats, + QuestionCoordinatorStats, +} from '../../../domain/contributor_dashboard/contributor-dashboard-admin-summary.model'; import {AppConstants} from 'app.constants'; import {ContributorDashboardAdminPageConstants as PageConstants} from '../contributor-dashboard-admin-page.constants'; import {ClassroomBackendApiService} from 'domain/classroom/classroom-backend-api.service'; @@ -148,6 +150,41 @@ export interface QuestionReviewerStatsBackendDict { more: boolean; } +export interface TranslationCoordinatorBackendDict { + language_id: string; + translators_count: number; + reviewers_count: number; + coordinator_activity_list: { + translation_coordinator: string; + last_activity_days: number; + }[]; +} + +export interface QuestionCoordinatorBackendDict { + question_coordinator: string; + last_activity: number; +} + +export interface TranslationCoordinatorStatsBackendDict { + stats: TranslationCoordinatorBackendDict[]; +} + +export interface QuestionCoordinatorStatsBackendDict { + stats: QuestionCoordinatorBackendDict[]; +} + +export interface TranslationCoordinatorStatsData { + stats: TranslationCoordinatorStats[]; + nextOffset: number; + more: boolean; +} + +export interface QuestionCoordinatorStatsData { + stats: QuestionCoordinatorStats[]; + nextOffset: number; + more: boolean; +} + @Injectable({ providedIn: 'root', }) @@ -205,6 +242,8 @@ export class ContributorDashboardAdminStatsBackendApiService { | TranslationReviewerStatsData | QuestionSubmitterStatsData | QuestionReviewerStatsData + | TranslationCoordinatorStatsData + | QuestionCoordinatorStatsData > { const url = this.urlInterpolationService.interpolateUrl( PageConstants.CONTRIBUTOR_ADMIN_STATS_SUMMARIES_URL, @@ -277,6 +316,36 @@ export class ContributorDashboardAdminStatsBackendApiService { } ); }); + } else if ( + contributionSubtype === + AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE + ) { + return new Promise((resolve, reject) => { + this.http + .get(url, { + params: this.params, + } as Object) + .toPromise() + .then( + response => { + const stats: TranslationCoordinatorStats[] = []; + response.stats.forEach(languageDict => { + languageDict.coordinator_activity_list.forEach(activity => { + stats.push( + TranslationCoordinatorStats.createFromBackendDict( + languageDict, + activity + ) + ); + }); + }); + resolve({stats, nextOffset: 0, more: false}); + }, + errorResponse => { + reject(errorResponse.error.error); + } + ); + }); } } else if ( contributionType === AppConstants.CONTRIBUTION_STATS_TYPE_QUESTION @@ -330,6 +399,31 @@ export class ContributorDashboardAdminStatsBackendApiService { } ); }); + } else if ( + contributionSubtype === + AppConstants.CONTRIBUTION_STATS_SUBTYPE_COORDINATE + ) { + return new Promise((resolve, reject) => { + this.http + .get(url, { + params: this.params, + } as Object) + .toPromise() + .then( + response => { + resolve({ + stats: response.stats.map(backendDict => + QuestionCoordinatorStats.createFromBackendDict(backendDict) + ), + nextOffset: 0, + more: false, + }); + }, + errorResponse => { + reject(errorResponse.error.error); + } + ); + }); } } return Promise.resolve({ diff --git a/core/templates/pages/contributor-dashboard-admin-page/services/format-contributor-attributes.service.spec.ts b/core/templates/pages/contributor-dashboard-admin-page/services/format-contributor-attributes.service.spec.ts index 1f4dbae13cbef..4911580d4e057 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/services/format-contributor-attributes.service.spec.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/services/format-contributor-attributes.service.spec.ts @@ -18,11 +18,13 @@ import {FormatContributorAttributesService} from './format-contributor-attributes.service'; import { + QuestionCoordinatorStats, QuestionReviewerStats, QuestionSubmitterStats, + TranslationCoordinatorStats, TranslationReviewerStats, TranslationSubmitterStats, -} from '../contributor-dashboard-admin-summary.model'; +} from '../../../domain/contributor_dashboard/contributor-dashboard-admin-summary.model'; describe('Format contribution attributes service', () => { describe('when displaying the stats table', () => { @@ -200,5 +202,26 @@ describe('Format contribution attributes service', () => { }, ]); }); + + it('should return the translation coordinator stats attributes correctly', () => { + const service: FormatContributorAttributesService = + new FormatContributorAttributesService(); + const stats = new TranslationCoordinatorStats('user1', 'en', 5, 3, 10); + expect( + service.getTranslationCoordinatorContributorAttributes(stats) + ).toEqual([ + {key: 'Translators', displayText: '5'}, + {key: 'Reviewers', displayText: '3'}, + ]); + }); + + it('should return no attributes for question coordinators', () => { + const service: FormatContributorAttributesService = + new FormatContributorAttributesService(); + const stats = new QuestionCoordinatorStats('user1', 5); + expect( + service.getQuestionCoordinatorContributorAttributes(stats) + ).toEqual([]); + }); }); }); diff --git a/core/templates/pages/contributor-dashboard-admin-page/services/format-contributor-attributes.service.ts b/core/templates/pages/contributor-dashboard-admin-page/services/format-contributor-attributes.service.ts index 4ba18fac5e2b7..673a0da0a9e79 100644 --- a/core/templates/pages/contributor-dashboard-admin-page/services/format-contributor-attributes.service.ts +++ b/core/templates/pages/contributor-dashboard-admin-page/services/format-contributor-attributes.service.ts @@ -28,7 +28,9 @@ import { QuestionSubmitterStats, TranslationReviewerStats, TranslationSubmitterStats, -} from '../contributor-dashboard-admin-summary.model'; + TranslationCoordinatorStats, + QuestionCoordinatorStats, +} from '../../../domain/contributor_dashboard/contributor-dashboard-admin-summary.model'; import {AppConstants} from 'app.constants'; export interface ContributorAttribute { @@ -54,6 +56,8 @@ export class FormatContributorAttributesService { ACCEPTED_QUESTIONS = 'Accepted Questions'; REVIEWED_QUESTIONS = 'Reviewed Questions'; REJECTED_QUESTIONS = 'Rejected Questions'; + TRANSLATORS: string = 'Translators'; + REVIEWERS: string = 'Reviewers'; private pluralise(num: number, word: string): string { return num === 1 ? word : word + 's'; @@ -260,4 +264,28 @@ export class FormatContributorAttributesService { questionReviewerStats ); } + + getTranslationCoordinatorContributorAttributes( + stats: TranslationCoordinatorStats + ): ContributorAttribute[] { + const attributes: AttributeTable = { + [this.TRANSLATORS]: (s: TranslationCoordinatorStats) => + s.translatorsCount.toString(), + [this.REVIEWERS]: (s: TranslationCoordinatorStats) => + s.reviewersCount.toString(), + }; + return this.getContributorAttributes( + attributes as AttributeTable, + stats + ); + } + + getQuestionCoordinatorContributorAttributes( + stats: QuestionCoordinatorStats + ): ContributorAttribute[] { + return this.getContributorAttributes( + {} as AttributeTable, + stats + ); + } } diff --git a/core/templates/pages/contributor-dashboard-page/contributions-and-review/contributions-and-review.component.spec.ts b/core/templates/pages/contributor-dashboard-page/contributions-and-review/contributions-and-review.component.spec.ts index 239b5c00556ce..52b6d3b39ea26 100644 --- a/core/templates/pages/contributor-dashboard-page/contributions-and-review/contributions-and-review.component.spec.ts +++ b/core/templates/pages/contributor-dashboard-page/contributions-and-review/contributions-and-review.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for contributionsAndReview. */ +// @ts-nocheck + import { ComponentFixture, fakeAsync, diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/question-suggestion-editor-modal.component.spec.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/question-suggestion-editor-modal.component.spec.ts index df446f559ac20..b3ce5b7892502 100644 --- a/core/templates/pages/contributor-dashboard-page/modal-templates/question-suggestion-editor-modal.component.spec.ts +++ b/core/templates/pages/contributor-dashboard-page/modal-templates/question-suggestion-editor-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for QuestionSuggestionEditorModalComponent. */ +// @ts-nocheck + import {fakeAsync, tick} from '@angular/core/testing'; import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; import {NO_ERRORS_SCHEMA} from '@angular/core'; diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts index cfdfab244af57..13c7e39f89727 100644 --- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts +++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for TranslationModalComponent. */ +// @ts-nocheck + import { HttpClientTestingModule, HttpTestingController, @@ -311,6 +313,11 @@ describe('Translation Modal Component', () => { component.updateHtml('

Translated text

'); fixture.detectChanges(); + // The ngOnInit is still called by Angular Ivy's lifecycle mechanism despite + // the spy, so flush the HTTP request it creates. + httpTestingController + .expectOne('/gettranslatabletexthandler?exp_id=1&language_code=es') + .flush({state_names_to_content_id_mapping: {}, version: 1}); const saveButton: HTMLButtonElement = fixture.nativeElement.querySelector( '.e2e-test-save-button' @@ -333,6 +340,11 @@ describe('Translation Modal Component', () => { ''; component.updateHtml('

Translated text

'); fixture.detectChanges(); + // The ngOnInit is still called by Angular Ivy's lifecycle mechanism despite + // the spy, so flush the HTTP request it creates. + httpTestingController + .expectOne('/gettranslatabletexthandler?exp_id=1&language_code=es') + .flush({state_names_to_content_id_mapping: {}, version: 1}); const saveButton: HTMLButtonElement = fixture.nativeElement.querySelector( '.e2e-test-save-button' @@ -768,6 +780,7 @@ describe('Translation Modal Component', () => { describe('when skipping the active translation', () => { describe('when there is available text', () => { beforeEach(fakeAsync(() => { + spyOn(translateTextService, 'init').and.callThrough(); component.ngOnInit(); const sampleStateWiseContentMapping = { @@ -815,6 +828,7 @@ describe('Translation Modal Component', () => { }, files: {}, }; + spyOn(translateTextService, 'init').and.callThrough(); component.ngOnInit(); tick(); diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts index 30f654723c46c..1d33d72c55b56 100644 --- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts +++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for TranslationSuggestionReviewModalComponent. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import { ComponentFixture, diff --git a/core/templates/pages/contributor-dashboard-page/services/contribution-and-review.service.spec.ts b/core/templates/pages/contributor-dashboard-page/services/contribution-and-review.service.spec.ts index dc3af569e4d51..c2b6a198080e6 100644 --- a/core/templates/pages/contributor-dashboard-page/services/contribution-and-review.service.spec.ts +++ b/core/templates/pages/contributor-dashboard-page/services/contribution-and-review.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for contribution and review service */ +// @ts-nocheck + import {TestBed, fakeAsync, flushMicrotasks, tick} from '@angular/core/testing'; import {HttpClientTestingModule} from '@angular/common/http/testing'; import {AppConstants} from 'app.constants'; diff --git a/core/templates/pages/contributor-dashboard-page/services/translate-text-backend-api.service.spec.ts b/core/templates/pages/contributor-dashboard-page/services/translate-text-backend-api.service.spec.ts index 3b89b80acf265..60c2f904a52e8 100644 --- a/core/templates/pages/contributor-dashboard-page/services/translate-text-backend-api.service.spec.ts +++ b/core/templates/pages/contributor-dashboard-page/services/translate-text-backend-api.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Tests that translatable text backend api works correctly. */ +// @ts-nocheck + import {HttpErrorResponse} from '@angular/common/http'; import { HttpClientTestingModule, diff --git a/core/templates/pages/contributor-dashboard-page/services/translate-text.service.spec.ts b/core/templates/pages/contributor-dashboard-page/services/translate-text.service.spec.ts index f378051d2aa23..b337945425973 100644 --- a/core/templates/pages/contributor-dashboard-page/services/translate-text.service.spec.ts +++ b/core/templates/pages/contributor-dashboard-page/services/translate-text.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Tests for translate-text service. */ +// @ts-nocheck + import { HttpClientTestingModule, HttpTestingController, diff --git a/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.html b/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.html index e4372ae532994..fa818e5345a46 100644 --- a/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.html +++ b/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.html @@ -49,17 +49,42 @@

-

-

+ +

+

+

{{ dashboardStats.totalOpenFeedback }} -

-

- {{ dashboardStats.totalOpenFeedback }} -

+

+

+ {{ dashboardStats.totalOpenFeedback }} +

+ +
+

+

+ + {{ feedbackStatusCounts?.open || 0 }} Open + + + {{ feedbackStatusCounts?.fixed || 0 }} Fixed + + + {{ feedbackStatusCounts?.compliment || 0 }} Compliments + + + {{ feedbackStatusCounts?.not_actionable || 0 }} Not Actionable + + + {{ feedbackStatusCounts?.total || 0 }} Total + +

@@ -465,7 +490,7 @@

margin-left: -5px; } .oppia-mat-card { - align-items: baseline; + align-items: stretch; display: flex; } .oppia-create-question { @@ -956,4 +981,11 @@

width: fit-content; } } + + .oppia-feedback-status-breakdown { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; + } diff --git a/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.spec.ts b/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.spec.ts index a0fd7c13f1372..9ba06c74b4700 100644 --- a/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.spec.ts +++ b/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.spec.ts @@ -43,6 +43,16 @@ import {NO_ERRORS_SCHEMA, Pipe, PipeTransform} from '@angular/core'; import {HttpClientTestingModule} from '@angular/common/http/testing'; import {SortByPipe} from 'filters/string-utility-filters/sort-by.pipe'; import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; +import {PlatformFeatureService} from 'services/platform-feature.service'; +import {FeedbackBackendApiService} from 'domain/feedback/feedback-backend-api.service'; + +class MockPlatformFeatureService { + status = { + ExplorationEditorNewCreatorFeedbackTab: { + isEnabled: false, + }, + }; +} @Pipe({name: 'truncate'}) class MockTruncatePipe implements PipeTransform { @@ -51,6 +61,33 @@ class MockTruncatePipe implements PipeTransform { } } +const createExplorationBackendDict = (id: string, status: string) => ({ + human_readable_contributors_summary: {username: {num_commits: 3}}, + category: 'Algebra', + community_owned: false, + tags: [], + title: 'Testing Exploration', + created_on_msec: 1593786508029.501, + num_total_threads: 0, + num_views: 1, + last_updated_msec: 1593786607552.753, + status, + num_open_threads: 0, + thumbnail_icon_url: '/subjects/Algebra.svg', + language_code: 'en', + objective: 'To test exploration recommendations', + id, + thumbnail_bg_color: '#cc4b00', + activity_type: 'exploration', + ratings: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, +}); + describe('Creator Dashboard Page Component', () => { let fixture: ComponentFixture; let component: CreatorDashboardPageComponent; @@ -59,6 +96,8 @@ describe('Creator Dashboard Page Component', () => { let userService: UserService; let explorationCreationService: ExplorationCreationService; let windowDimensionsService: WindowDimensionsService; + let feedbackBackendApiService: FeedbackBackendApiService; + let mockPlatformFeatureService = new MockPlatformFeatureService(); let userInfo = { canCreateCollections: () => true, }; @@ -72,12 +111,19 @@ describe('Creator Dashboard Page Component', () => { MockTruncatePipe, SortByPipe, ], - providers: [], + providers: [ + { + provide: PlatformFeatureService, + useValue: mockPlatformFeatureService, + }, + ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); beforeEach(waitForAsync(() => { + mockPlatformFeatureService.status.ExplorationEditorNewCreatorFeedbackTab.isEnabled = + false; fixture = TestBed.createComponent(CreatorDashboardPageComponent); component = fixture.componentInstance; creatorDashboardBackendApiService = TestBed.inject( @@ -87,6 +133,7 @@ describe('Creator Dashboard Page Component', () => { explorationCreationService = TestBed.inject(ExplorationCreationService); userService = TestBed.inject(UserService); windowDimensionsService = TestBed.inject(WindowDimensionsService); + feedbackBackendApiService = TestBed.inject(FeedbackBackendApiService); spyOn(csrfService, 'getTokenAsync').and.returnValue( Promise.resolve('sample-csrf-token') @@ -359,6 +406,16 @@ describe('Creator Dashboard Page Component', () => { component.ngOnInit(); })); + it('should not fetch feedback status counts when the feature flag is disabled', () => { + const fetchSpy = spyOn( + feedbackBackendApiService, + 'fetchLessonFeedbackStatusCountsAsync' + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(component.feedbackStatusCounts).toBeNull(); + }); + it( 'should save the exploration format view in the backend when creator' + ' changes the format view', @@ -524,6 +581,141 @@ describe('Creator Dashboard Page Component', () => { })); }); + const createFeedbackStatusCountsTestDashboardData = ( + explorationDicts: ReturnType[] + ): Promise => { + return Promise.resolve({ + dashboardStats: CreatorDashboardStats.createFromBackendDict({ + average_ratings: 0, + num_ratings: 0, + total_open_feedback: 0, + total_plays: 10, + }), + lastWeekStats: null, + displayPreference: 'card', + subscribersList: [], + explorationsList: explorationDicts.map(dict => + CreatorExplorationSummary.createFromBackendDict(dict) + ), + collectionsList: [], + } as unknown as CreatorDashboardData); + }; + + describe('when the new creator feedback feature flag is enabled', () => { + const countsResponse = { + lesson_feedback_counts: { + open: 2, + fixed: 1, + compliment: 0, + not_actionable: 0, + transferred_to_github: 0, + total: 3, + }, + platform_report_counts: { + open: 9, + fixed: 9, + compliment: 9, + not_actionable: 9, + transferred_to_github: 9, + total: 45, + }, + }; + + beforeEach(waitForAsync(() => { + mockPlatformFeatureService.status.ExplorationEditorNewCreatorFeedbackTab.isEnabled = + true; + spyOn( + creatorDashboardBackendApiService, + 'fetchDashboardDataAsync' + ).and.returnValue( + createFeedbackStatusCountsTestDashboardData([ + createExplorationBackendDict('publicExp1', 'public'), + createExplorationBackendDict('privateExp1', 'private'), + ]) + ); + spyOn( + feedbackBackendApiService, + 'fetchLessonFeedbackStatusCountsAsync' + ).and.returnValue(Promise.resolve(countsResponse)); + spyOn(userService, 'getUserInfoAsync').and.returnValue( + Promise.resolve(userInfo as UserInfo) + ); + + component.ngOnInit(); + })); + + it('should aggregate lesson feedback status counts across public explorations only', () => { + expect( + feedbackBackendApiService.fetchLessonFeedbackStatusCountsAsync + ).toHaveBeenCalledTimes(1); + expect( + feedbackBackendApiService.fetchLessonFeedbackStatusCountsAsync + ).toHaveBeenCalledWith('publicExp1'); + expect(component.feedbackStatusCounts).toEqual( + countsResponse.lesson_feedback_counts + ); + }); + }); + + describe('when the new creator feedback feature flag is enabled and a status count request fails', () => { + beforeEach(waitForAsync(() => { + mockPlatformFeatureService.status.ExplorationEditorNewCreatorFeedbackTab.isEnabled = + true; + spyOn( + creatorDashboardBackendApiService, + 'fetchDashboardDataAsync' + ).and.returnValue( + createFeedbackStatusCountsTestDashboardData([ + createExplorationBackendDict('publicExp1', 'public'), + ]) + ); + spyOn( + feedbackBackendApiService, + 'fetchLessonFeedbackStatusCountsAsync' + ).and.returnValue(Promise.reject()); + spyOn(userService, 'getUserInfoAsync').and.returnValue( + Promise.resolve(userInfo as UserInfo) + ); + + component.ngOnInit(); + })); + + it('should hide the status breakdown', () => { + expect(component.feedbackStatusCounts).toBeNull(); + }); + }); + + describe('when the new creator feedback feature flag is enabled with no public explorations', () => { + let fetchCountsSpy: jasmine.Spy; + + beforeEach(waitForAsync(() => { + mockPlatformFeatureService.status.ExplorationEditorNewCreatorFeedbackTab.isEnabled = + true; + spyOn( + creatorDashboardBackendApiService, + 'fetchDashboardDataAsync' + ).and.returnValue( + createFeedbackStatusCountsTestDashboardData([ + createExplorationBackendDict('privateExp1', 'private'), + ]) + ); + fetchCountsSpy = spyOn( + feedbackBackendApiService, + 'fetchLessonFeedbackStatusCountsAsync' + ); + spyOn(userService, 'getUserInfoAsync').and.returnValue( + Promise.resolve(userInfo as UserInfo) + ); + + component.ngOnInit(); + })); + + it('should not fetch any status counts', () => { + expect(fetchCountsSpy).not.toHaveBeenCalled(); + expect(component.feedbackStatusCounts).toBeNull(); + }); + }); + describe('when on collections tab', () => { let dashboardData = { explorations_list: [], diff --git a/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.ts b/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.ts index 3b0c5e3104d18..8620a35adbe8a 100644 --- a/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.ts +++ b/core/templates/pages/creator-dashboard-page/creator-dashboard-page.component.ts @@ -36,6 +36,12 @@ import {CollectionSummary} from 'domain/collection/collection-summary.model'; import {ExplorationRatings} from 'domain/summary/learner-exploration-summary.model'; import {CreatorDashboardStats} from 'domain/creator_dashboard/creator-dashboard-stats.model'; import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; +import {PlatformFeatureService} from 'services/platform-feature.service'; +import {FeedbackBackendApiService} from 'domain/feedback/feedback-backend-api.service'; +import { + FeedbackStatusCounts, + FeedbackStatusCountsBackendResponse, +} from 'domain/feedback/feedback.model'; @Component({ selector: 'oppia-creator-dashboard-page', @@ -57,6 +63,7 @@ export class CreatorDashboardPageComponent { // 'lastWeekStats' is null for a new creator. lastWeekStats!: CreatorDashboardStats | null; dashboardStats!: CreatorDashboardStats; + feedbackStatusCounts: FeedbackStatusCounts | null = null; relativeChangeInTotalPlays!: number; getLocaleAbbreviatedDatetimeString!: (millisSinceEpoch: number) => string; getHumanReadableStatus!: (status: string) => string; @@ -95,7 +102,9 @@ export class CreatorDashboardPageComponent { private dateTimeFormatService: DateTimeFormatService, private threadStatusDisplayService: ThreadStatusDisplayService, private explorationCreationService: ExplorationCreationService, - private windowRef: WindowRef + private windowRef: WindowRef, + private platformFeatureService: PlatformFeatureService, + private feedbackBackendApiService: FeedbackBackendApiService ) {} EXP_PUBLISH_TEXTS = { @@ -239,6 +248,10 @@ export class CreatorDashboardPageComponent { this.lastWeekStats = responseData.lastWeekStats; this.myExplorationsView = responseData.displayPreference; + if (this.isNewCreatorFeedbackTabEnabled()) { + this.fetchAggregatedFeedbackStatusCounts(); + } + if (this.dashboardStats && this.lastWeekStats) { this.relativeChangeInTotalPlays = this.dashboardStats.totalPlays - this.lastWeekStats.totalPlays; @@ -280,6 +293,60 @@ export class CreatorDashboardPageComponent { this.explorationCreationService.createNewExploration(); } + isNewCreatorFeedbackTabEnabled(): boolean { + return this.platformFeatureService.status + .ExplorationEditorNewCreatorFeedbackTab.isEnabled; + } + + private fetchAggregatedFeedbackStatusCounts(): void { + const publicExplorationIds = this.explorationsList + .filter(exploration => exploration.status !== 'private') + .map(exploration => exploration.id); + + if (publicExplorationIds.length === 0) { + return; + } + + Promise.all( + publicExplorationIds.map(explorationId => + this.feedbackBackendApiService.fetchLessonFeedbackStatusCountsAsync( + explorationId + ) + ) + ) + .then(responses => { + this.feedbackStatusCounts = this.mergeFeedbackStatusCounts(responses); + }) + .catch(() => { + // The status breakdown is supplementary, so it stays hidden if any + // of its requests fails. + this.feedbackStatusCounts = null; + }); + } + + private mergeFeedbackStatusCounts( + responses: FeedbackStatusCountsBackendResponse[] + ): FeedbackStatusCounts { + const merged: FeedbackStatusCounts = { + open: 0, + fixed: 0, + compliment: 0, + not_actionable: 0, + transferred_to_github: 0, + total: 0, + }; + responses.forEach(response => { + const counts = response.lesson_feedback_counts; + merged.open += counts.open; + merged.fixed += counts.fixed; + merged.compliment += counts.compliment; + merged.not_actionable += counts.not_actionable; + merged.transferred_to_github += counts.transferred_to_github; + merged.total += counts.total; + }); + return merged; + } + returnZero(): number { // This function is used as a custom function to // sort heading in the list view. Directly assigning diff --git a/core/templates/pages/creator-dashboard-page/modal-templates/upload-activity-modal.component.spec.ts b/core/templates/pages/creator-dashboard-page/modal-templates/upload-activity-modal.component.spec.ts index d88e0c76684e5..9d98bf1f8a750 100644 --- a/core/templates/pages/creator-dashboard-page/modal-templates/upload-activity-modal.component.spec.ts +++ b/core/templates/pages/creator-dashboard-page/modal-templates/upload-activity-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for UploadActivityModalComponent. */ +// @ts-nocheck + import {AlertsService} from 'services/alerts.service'; import { ComponentFixture, diff --git a/core/templates/pages/diagnostic-test-player-page/diagnostic-test-player.component.spec.ts b/core/templates/pages/diagnostic-test-player-page/diagnostic-test-player.component.spec.ts index a3b92dfcfe08a..5233426495dfb 100644 --- a/core/templates/pages/diagnostic-test-player-page/diagnostic-test-player.component.spec.ts +++ b/core/templates/pages/diagnostic-test-player-page/diagnostic-test-player.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Tests for the diagnostic test player component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import { ComponentFixture, diff --git a/core/templates/pages/donate-page/donate-page-root.component.spec.ts b/core/templates/pages/donate-page/donate-page-root.component.spec.ts index 51709ef3cb0a3..f10268624e411 100644 --- a/core/templates/pages/donate-page/donate-page-root.component.spec.ts +++ b/core/templates/pages/donate-page/donate-page-root.component.spec.ts @@ -16,13 +16,26 @@ * @fileoverview Unit tests for the donate page root component. */ +// @ts-nocheck + import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {TranslateService} from '@ngx-translate/core'; import {AppConstants} from 'app.constants'; import {MockTranslatePipe} from 'tests/unit-test-utils'; import {DonatePageRootComponent} from './donate-page-root.component'; +class MockTranslateService { + instant(key: string): string { + return key; + } + onLangChange = {subscribe: (): void => {}}; + get(key: string): {subscribe: () => void} { + return {subscribe: (): void => {}}; + } +} + describe('Donate Page Root', () => { let fixture: ComponentFixture; let component: DonatePageRootComponent; @@ -30,7 +43,7 @@ describe('Donate Page Root', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [DonatePageRootComponent, MockTranslatePipe], - providers: [], + providers: [{provide: TranslateService, useClass: MockTranslateService}], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(DonatePageRootComponent); diff --git a/core/templates/pages/exploration-editor-page/editor-navigation/editor-navigation.component.spec.ts b/core/templates/pages/exploration-editor-page/editor-navigation/editor-navigation.component.spec.ts index d929443d70d7e..dcad5d4fe4c13 100644 --- a/core/templates/pages/exploration-editor-page/editor-navigation/editor-navigation.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/editor-navigation/editor-navigation.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for editorNavigation. */ +// @ts-nocheck + import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { ComponentFixture, diff --git a/core/templates/pages/exploration-editor-page/editor-tab/graph-directives/state-graph-visualization.component.spec.ts b/core/templates/pages/exploration-editor-page/editor-tab/graph-directives/state-graph-visualization.component.spec.ts index b672fcb28d825..b507508650da6 100644 --- a/core/templates/pages/exploration-editor-page/editor-tab/graph-directives/state-graph-visualization.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/editor-tab/graph-directives/state-graph-visualization.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for State Graph Visualization directive. */ +// @ts-nocheck + import { EventEmitter, NO_ERRORS_SCHEMA, @@ -315,7 +317,7 @@ describe('State Graph Visualization Component when graph is redrawn', () => { component.ngOnInit(); tick(); - expect(component.graphLoaded).toBeTrue(); + expect(component.graphLoaded).toBe(true); expect(component.GRAPH_WIDTH).toBe(630); expect(component.GRAPH_HEIGHT).toBe(280); expect(component.VIEWPORT_WIDTH).toBe('10000px'); @@ -467,29 +469,45 @@ describe('State Graph Visualization Component when graph is redrawn', () => { right: 20, }); flush(); - // Spies for d3 library. - var zoomSpy = jasmine.createSpy('zoom').and.returnValue({ - scaleExtent: () => ({ - on: (evt: string, callback: () => void) => { - callback(); - return { - apply: () => {}, - }; - }, - }), - }); - spyOnProperty(d3, 'zoom').and.returnValue(zoomSpy); - spyOnProperty(d3, 'event').and.returnValue({ - transform: { - x: 10, - y: 20, - }, + let zoomListener: (...args: Object[]) => void = () => {}; + const originalCall = d3.selection.prototype.call; + spyOn(d3.selection.prototype, 'call').and.callFake(function ( + this: Object, + behavior: Object, + ...args: Object[] + ) { + const zoomBehavior = behavior as { + on?: (eventName: string) => (...args: Object[]) => void; + }; + if (zoomBehavior && typeof zoomBehavior.on === 'function') { + const listener = zoomBehavior.on('zoom'); + if (listener) { + zoomListener = listener; + } + } + return originalCall.apply(this, [behavior as Object, ...args]); }); component.makeGraphPannable(); tick(); flush(); + const mockEvent = { + transform: { + x: 10, + y: 20, + }, + }; + const pannableRect = fixture.nativeElement.querySelector('#pannableRect'); + const d3WithCustomEvent = d3 as Object as { + customEvent: ( + event: Object, + listener: (...args: Object[]) => void, + target: Object + ) => void; + }; + d3WithCustomEvent.customEvent(mockEvent, zoomListener, pannableRect); + expect(component.innerTransformStr).toBe('translate(10,20)'); }) ); @@ -521,31 +539,47 @@ describe('State Graph Visualization Component when graph is redrawn', () => { }; flush(); - // Spies for d3 library. - var zoomSpy = jasmine.createSpy('zoom').and.returnValue({ - scaleExtent: () => ({ - on: (evt: string, callback: () => void) => { - callback(); - return { - apply: () => {}, - }; - }, - }), - }); - spyOnProperty(d3, 'zoom').and.returnValue(zoomSpy); - spyOnProperty(d3, 'event').and.returnValue({ - transform: { - x: 10, - y: 20, - }, + let zoomListener: (...args: Object[]) => void = () => {}; + const originalCall = d3.selection.prototype.call; + spyOn(d3.selection.prototype, 'call').and.callFake(function ( + this: Object, + behavior: Object, + ...args: Object[] + ) { + const zoomBehavior = behavior as { + on?: (eventName: string) => (...args: Object[]) => void; + }; + if (zoomBehavior && typeof zoomBehavior.on === 'function') { + const listener = zoomBehavior.on('zoom'); + if (listener) { + zoomListener = listener; + } + } + return originalCall.apply(this, [behavior as Object, ...args]); }); component.makeGraphPannable(); tick(); flush(); - expect(d3.event.transform.x).toBe(0); - expect(d3.event.transform.y).toBe(0); + const mockEvent = { + transform: { + x: 10, + y: 20, + }, + }; + const pannableRect = fixture.nativeElement.querySelector('#pannableRect'); + const d3WithCustomEvent = d3 as Object as { + customEvent: ( + event: Object, + listener: (...args: Object[]) => void, + target: Object + ) => void; + }; + d3WithCustomEvent.customEvent(mockEvent, zoomListener, pannableRect); + + expect(mockEvent.transform.x).toBe(0); + expect(mockEvent.transform.y).toBe(0); expect(component.overallTransformStr).toBe('translate(465,487.5)'); }) ); diff --git a/core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/customize-interaction-modal.component.spec.ts b/core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/customize-interaction-modal.component.spec.ts index 81d5545d544c9..48f617be24d8c 100644 --- a/core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/customize-interaction-modal.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/customize-interaction-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for Customize Interaction Modal. */ +// @ts-nocheck + import { ChangeDetectorRef, EventEmitter, diff --git a/core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/teach-oppia-modal.component.spec.ts b/core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/teach-oppia-modal.component.spec.ts index bcda365a8fe2d..41ec7846d44ab 100644 --- a/core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/teach-oppia-modal.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/teach-oppia-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for TeachOppiaModalComponent. */ +// @ts-nocheck + import {EventEmitter, Injector, NO_ERRORS_SCHEMA} from '@angular/core'; import { ComponentFixture, @@ -470,5 +472,24 @@ describe('Teach Oppia Modal Component', () => { expect(component.showUnresolvedAnswers).toHaveBeenCalled(); })); + + it('should log an error and show no unresolved answers when fetching fails', fakeAsync(() => { + let rejectedResponse = { + data: 'Error fetching unresolved answers', + }; + + spyOn(console, 'error'); + spyOn(component, 'showUnresolvedAnswers').and.stub(); + spyOn( + teachOppiaModalBackendApiService, + 'fetchTeachOppiaModalDataAsync' + ).and.returnValue(Promise.reject(rejectedResponse)); + + component.ngOnInit(); + tick(); + + expect(console.error).toHaveBeenCalled(); + expect(component.showUnresolvedAnswers).toHaveBeenCalledWith([]); + })); }); }); diff --git a/core/templates/pages/exploration-editor-page/editor-tab/training-panel/training-data-editor-panel-modal.component.spec.ts b/core/templates/pages/exploration-editor-page/editor-tab/training-panel/training-data-editor-panel-modal.component.spec.ts index 63fc49992cd6b..727b6dab104a8 100644 --- a/core/templates/pages/exploration-editor-page/editor-tab/training-panel/training-data-editor-panel-modal.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/editor-tab/training-panel/training-data-editor-panel-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for TrainingDataEditorPanelServiceModalcomponent. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { diff --git a/core/templates/pages/exploration-editor-page/exploration-editor-page.component.spec.ts b/core/templates/pages/exploration-editor-page/exploration-editor-page.component.spec.ts index 5fdb54eda0640..4047e966d8001 100644 --- a/core/templates/pages/exploration-editor-page/exploration-editor-page.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/exploration-editor-page.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for exploration editor page component. */ +// @ts-nocheck + import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { TestBed, diff --git a/core/templates/pages/exploration-editor-page/exploration-save-and-publish-buttons/exploration-save-and-publish-buttons.component.spec.ts b/core/templates/pages/exploration-editor-page/exploration-save-and-publish-buttons/exploration-save-and-publish-buttons.component.spec.ts index 61d74146150cf..e1f6665c8787c 100644 --- a/core/templates/pages/exploration-editor-page/exploration-save-and-publish-buttons/exploration-save-and-publish-buttons.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/exploration-save-and-publish-buttons/exploration-save-and-publish-buttons.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for explorationSaveAndPublishButtons. */ +// @ts-nocheck + import {NO_ERRORS_SCHEMA} from '@angular/core'; import { ComponentFixture, diff --git a/core/templates/pages/exploration-editor-page/feedback-tab/feedback-tab.component.spec.ts b/core/templates/pages/exploration-editor-page/feedback-tab/feedback-tab.component.spec.ts index 45698eec4cfcd..20653d9e847e9 100644 --- a/core/templates/pages/exploration-editor-page/feedback-tab/feedback-tab.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/feedback-tab/feedback-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for feedbackTab. */ +// @ts-nocheck + import { ComponentFixture, fakeAsync, @@ -38,6 +40,7 @@ import {ThreadDataBackendApiService} from './services/thread-data-backend-api.se import {FeedbackTabComponent} from './feedback-tab.component'; import {UserInfo} from 'domain/user/user-info.model'; import {FeedbackThread} from 'domain/feedback_thread/feedback-thread.model'; +import {ExplorationPermissions} from 'domain/exploration/exploration-permissions.model'; import {PlatformFeatureService} from 'services/platform-feature.service'; import {WindowRef} from 'services/contextual/window-ref.service'; import {PageContextService} from 'services/page-context.service'; @@ -71,9 +74,12 @@ class MockPlatformFeatureService { const mockLessonFeedbackSummary: LessonFeedbackSummary = { id: 'lesson_feedback_1', feedback_text_preview: 'Lesson feedback', + latest_response_preview: '', status: FeedbackStatus.OPEN, source: ReportType.LESSON, + lesson_title: 'exp_1', unread_response_count: 0, + last_updated_msecs: 12345, }; const mockPlatformFeedbackSummary: PlatformFeedbackSummary = { @@ -235,7 +241,7 @@ describe('Feedback Tab Component', () => { ).and.returnValue( Promise.resolve({ canEdit: true, - }) + } as ExplorationPermissions) ); spyOn( threadDataBackendApiService, @@ -919,9 +925,12 @@ describe('Feedback Tab Component', () => { const lessonFeedbackSummary: LessonFeedbackSummary = { id: 'id', feedback_text_preview: 'Need help with fractions', + latest_response_preview: '', status: FeedbackStatus.OPEN, source: ReportType.LESSON, + lesson_title: 'exp_1', unread_response_count: 0, + last_updated_msecs: 12345, }; component.currentCreatorFeedbackFilterState.creatorFeedbackType = diff --git a/core/templates/pages/exploration-editor-page/feedback-tab/feedback-tab.component.ts b/core/templates/pages/exploration-editor-page/feedback-tab/feedback-tab.component.ts index 86414c812f54a..3e5f5e038ed0d 100644 --- a/core/templates/pages/exploration-editor-page/feedback-tab/feedback-tab.component.ts +++ b/core/templates/pages/exploration-editor-page/feedback-tab/feedback-tab.component.ts @@ -76,17 +76,21 @@ export class FeedbackTabComponent implements OnInit, OnDestroy { readonly creatorReportFeedbackCardConfig: FeedbackCardConfig = { showCategory: true, + showLesson: false, showResponse: false, showScreenshot: true, showLessonMetadata: true, showSessionInfo: false, + showNotificationSummary: false, }; readonly creatorLessonFeedbackCardConfig: FeedbackCardConfig = { showCategory: false, + showLesson: false, showResponse: true, showScreenshot: false, showLessonMetadata: true, showSessionInfo: false, + showNotificationSummary: false, }; readonly creatorFeedbackFilterConfig = CREATOR_DASHBOARD_FILTER_CONFIG; diff --git a/core/templates/pages/exploration-editor-page/history-tab/history-tab.component.spec.ts b/core/templates/pages/exploration-editor-page/history-tab/history-tab.component.spec.ts index 488216ab0bec0..2d5877576fe28 100644 --- a/core/templates/pages/exploration-editor-page/history-tab/history-tab.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/history-tab/history-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the exploration history tab. */ +// @ts-nocheck + import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import {EditabilityService} from 'services/editability.service'; import { diff --git a/core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.spec.ts b/core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.spec.ts index a344a9fb37b6c..b13ca6301a525 100644 --- a/core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for previewTab. */ +// @ts-nocheck + import { ComponentFixture, fakeAsync, diff --git a/core/templates/pages/exploration-editor-page/services/exploration-data-backend-api.service.spec.ts b/core/templates/pages/exploration-editor-page/services/exploration-data-backend-api.service.spec.ts index e5e3eeb90bf6a..3a4ece2e99de0 100644 --- a/core/templates/pages/exploration-editor-page/services/exploration-data-backend-api.service.spec.ts +++ b/core/templates/pages/exploration-editor-page/services/exploration-data-backend-api.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the Exploration data backend api service. */ +// @ts-nocheck + import { HttpClientTestingModule, HttpTestingController, diff --git a/core/templates/pages/exploration-editor-page/services/router.service.spec.ts b/core/templates/pages/exploration-editor-page/services/router.service.spec.ts index bec63ad1deccb..5080c58fd91f2 100644 --- a/core/templates/pages/exploration-editor-page/services/router.service.spec.ts +++ b/core/templates/pages/exploration-editor-page/services/router.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for RouterService. */ +// @ts-nocheck + import {RouterService} from './router.service'; import {Subscription} from 'rxjs'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; diff --git a/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.spec.ts b/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.spec.ts index 5381ab2c8feaa..8b519737e86c0 100644 --- a/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for settingsTab. */ +// @ts-nocheck + import { ChangeDetectorRef, EventEmitter, diff --git a/core/templates/pages/exploration-editor-page/statistics-tab/charts/pie-chart.component.spec.ts b/core/templates/pages/exploration-editor-page/statistics-tab/charts/pie-chart.component.spec.ts index a0dc4c7d4291e..17e5fc9267186 100644 --- a/core/templates/pages/exploration-editor-page/statistics-tab/charts/pie-chart.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/statistics-tab/charts/pie-chart.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for pieChart. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { @@ -73,6 +75,7 @@ describe('Pie Chart component', () => { // ref: https://github.com/jasmine/jasmine/issues/1415 Object.defineProperty(window, 'google', { get: () => ({}), + configurable: true, }); component.data = []; component.chart = mockedChart; diff --git a/core/templates/pages/exploration-editor-page/statistics-tab/statistics-tab.component.ts b/core/templates/pages/exploration-editor-page/statistics-tab/statistics-tab.component.ts index e829c27af6ded..1e5e824b2b9a8 100644 --- a/core/templates/pages/exploration-editor-page/statistics-tab/statistics-tab.component.ts +++ b/core/templates/pages/exploration-editor-page/statistics-tab/statistics-tab.component.ts @@ -49,6 +49,7 @@ interface PieChartOptions { @Component({ selector: 'oppia-statistics-tab', templateUrl: './statistics-tab.component.html', + styleUrls: ['./statistics-tab.component.css'], }) export class StatisticsTabComponent implements OnInit, OnDestroy { directiveSubscriptions = new Subscription(); diff --git a/core/templates/pages/exploration-editor-page/translation-tab/state-translation/state-translation.component.spec.ts b/core/templates/pages/exploration-editor-page/translation-tab/state-translation/state-translation.component.spec.ts index 32cb34f83c297..661d7a1589609 100644 --- a/core/templates/pages/exploration-editor-page/translation-tab/state-translation/state-translation.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/translation-tab/state-translation/state-translation.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for stateTranslation. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import { EventEmitter, diff --git a/core/templates/pages/exploration-editor-page/translation-tab/translation-tab.component.spec.ts b/core/templates/pages/exploration-editor-page/translation-tab/translation-tab.component.spec.ts index 637c890127e35..c108a9e50676d 100644 --- a/core/templates/pages/exploration-editor-page/translation-tab/translation-tab.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/translation-tab/translation-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for translationTab. */ +// @ts-nocheck + import { ComponentFixture, fakeAsync, diff --git a/core/templates/pages/exploration-editor-page/translation-tab/voiceover-card/voiceover-card.component.spec.ts b/core/templates/pages/exploration-editor-page/translation-tab/voiceover-card/voiceover-card.component.spec.ts index 9981be3d73e23..412607f237070 100644 --- a/core/templates/pages/exploration-editor-page/translation-tab/voiceover-card/voiceover-card.component.spec.ts +++ b/core/templates/pages/exploration-editor-page/translation-tab/voiceover-card/voiceover-card.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for VoiceoverCardComponent. */ +// @ts-nocheck + import { ComponentFixture, fakeAsync, diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/exploration-player-page-auth.guard.spec.ts b/core/templates/pages/exploration-player-page/current-lesson-player/exploration-player-page-auth.guard.spec.ts index 2bb2fff618e79..91339d7d1d129 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/exploration-player-page-auth.guard.spec.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/exploration-player-page-auth.guard.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Tests for ExplorationPlayerPageAuthGuard. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {TestBed} from '@angular/core/testing'; import { diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/content-language-selector.component.spec.ts b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/content-language-selector.component.spec.ts index f6f1f19fdc2b5..93d19dd24f40e 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/content-language-selector.component.spec.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/content-language-selector.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the CkEditor copy toolbar component. */ +// @ts-nocheck + import { waitForAsync, ComponentFixture, diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.spec.ts b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.spec.ts index 6b85dbbd09f28..5cbd6c63e4ab4 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.spec.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for Conversation skin component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts index aa9e6694ecc98..b674b77746f8c 100644 --- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts +++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for lesson information card modal component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {NO_ERRORS_SCHEMA, Pipe, PipeTransform} from '@angular/core'; import {EventEmitter} from '@angular/core'; @@ -50,6 +52,7 @@ import {RatingComputationService} from '../../../../components/ratings/rating-co import {CheckpointCelebrationUtilityService} from '../../services/checkpoint-celebration-utility.service'; import {PlayerPositionService} from '../../services/player-position.service'; import {StateCard} from '../../../../domain/state_card/state-card.model'; +import {State} from '../../../../domain/state/state.model'; import {ExplorationModeService} from '../../services/exploration-mode.service'; @Pipe({name: 'truncateAndCapitalize'}) @@ -593,7 +596,7 @@ describe('Lesson Information card modal component', () => { spyOn(explorationEngineService, 'getStateFromStateName').and.callFake( (stateName: string) => { - return {cardIsCheckpoint: parseInt(stateName) % 2 === 0}; + return {cardIsCheckpoint: parseInt(stateName) % 2 === 0} as State; } ); diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.spec.ts index 06c132f7303ec..25d2c7c4d189d 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the Conversation display Component. */ +// @ts-nocheck + import {SimpleChanges} from '@angular/core'; import {HttpClientTestingModule} from '@angular/common/http/testing'; import {NO_ERRORS_SCHEMA, EventEmitter} from '@angular/core'; diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/hint-solution-and-concept-card-display.component.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/hint-solution-and-concept-card-display.component.spec.ts index 5b71cafedfef0..4a7a03ebe55e3 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/hint-solution-and-concept-card-display.component.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/hint-solution-and-concept-card-display.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for HintSolutionAndConceptCardDisplayComponent */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {ChangeDetectorRef, EventEmitter} from '@angular/core'; import { diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.spec.ts index ba7522a3adae0..87302b725432a 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for ratings and recommendations component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import { EventEmitter, diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.spec.ts index b4122179b3bf9..49bffcddf57d7 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for new Conversation skin component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-auth.guard.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-auth.guard.spec.ts index 1f5a711edaccf..0990b6f679769 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-auth.guard.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-auth.guard.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Tests for new lesson player flag guard */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {TestBed} from '@angular/core/testing'; import { diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page.component.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page.component.spec.ts index ab560fb6c68d4..b5e8dfa2e3b29 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page.component.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page.component.spec.ts @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {NO_ERRORS_SCHEMA, EventEmitter} from '@angular/core'; import { diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/flag-lesson-modal.component.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/flag-lesson-modal.component.spec.ts index 4a0c006cdb131..7b88e8ebe7d21 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/flag-lesson-modal.component.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/flag-lesson-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for FlagExplorationModalComponent. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import { ComponentFixture, diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.spec.ts index a72806759a9ff..a8f354188ccaf 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for LessonFeedbackModalComponent. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {FormsModule} from '@angular/forms'; diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-player-sidebar.component.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-player-sidebar.component.spec.ts index b4a81d7dc56c5..5cb4c9b6ae53c 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-player-sidebar.component.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-player-sidebar.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for new lesson player sidebar component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import { ComponentFixture, diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.spec.ts b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.spec.ts index b870f9f61c2c3..298e17a2d7d64 100644 --- a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.spec.ts +++ b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for ShareLessonModalComponent. */ +// @ts-nocheck + import { ComponentFixture, TestBed, diff --git a/core/templates/pages/exploration-player-page/services/checkpoint-progress.service.spec.ts b/core/templates/pages/exploration-player-page/services/checkpoint-progress.service.spec.ts index a21c4433b228f..6a96a1b13884f 100644 --- a/core/templates/pages/exploration-player-page/services/checkpoint-progress.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/checkpoint-progress.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the CheckpointProgressService. */ +// @ts-nocheck + import {TestBed, waitForAsync} from '@angular/core/testing'; import {CheckpointProgressService} from './checkpoint-progress.service'; import {HttpClientTestingModule} from '@angular/common/http/testing'; diff --git a/core/templates/pages/exploration-player-page/services/content-translation-manager.service.spec.ts b/core/templates/pages/exploration-player-page/services/content-translation-manager.service.spec.ts index e2be6d1bc1e1c..082d621a9d1e4 100644 --- a/core/templates/pages/exploration-player-page/services/content-translation-manager.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/content-translation-manager.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the content translation manager service. */ +// @ts-nocheck + import { discardPeriodicTasks, fakeAsync, diff --git a/core/templates/pages/exploration-player-page/services/conversation-flow.service.spec.ts b/core/templates/pages/exploration-player-page/services/conversation-flow.service.spec.ts index 5c093a81fa96f..481fbd67f5258 100644 --- a/core/templates/pages/exploration-player-page/services/conversation-flow.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/conversation-flow.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for conversation flow service. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import { diff --git a/core/templates/pages/exploration-player-page/services/exploration-engine.service.spec.ts b/core/templates/pages/exploration-player-page/services/exploration-engine.service.spec.ts index cdd4edf15edab..8955eaf0e927b 100644 --- a/core/templates/pages/exploration-player-page/services/exploration-engine.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/exploration-engine.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the exploration engine service. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {fakeAsync, TestBed, tick} from '@angular/core/testing'; import {TranslateService} from '@ngx-translate/core'; diff --git a/core/templates/pages/exploration-player-page/services/exploration-initialization.service.spec.ts b/core/templates/pages/exploration-player-page/services/exploration-initialization.service.spec.ts index 7e7bf636a5d62..faf355b0cf103 100644 --- a/core/templates/pages/exploration-player-page/services/exploration-initialization.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/exploration-initialization.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for ExplorationInitializationService. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {TestBed, fakeAsync, tick, waitForAsync} from '@angular/core/testing'; import {ExplorationInitializationService} from './exploration-initialization.service'; diff --git a/core/templates/pages/exploration-player-page/services/learner-answer-info.service.spec.ts b/core/templates/pages/exploration-player-page/services/learner-answer-info.service.spec.ts index 663206e1de036..fce43935d28be 100644 --- a/core/templates/pages/exploration-player-page/services/learner-answer-info.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/learner-answer-info.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the learner answer info service. */ +// @ts-nocheck + import {TestBed} from '@angular/core/testing'; import {AnswerClassificationResult} from '../../../domain/classifier/answer-classification-result.model'; import {Outcome} from '../../../domain/exploration/outcome.model'; diff --git a/core/templates/pages/exploration-player-page/services/player-position.service.spec.ts b/core/templates/pages/exploration-player-page/services/player-position.service.spec.ts index 8607d9ba94ae3..f16891e073e92 100644 --- a/core/templates/pages/exploration-player-page/services/player-position.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/player-position.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the player position service. */ +// @ts-nocheck + import {EventEmitter} from '@angular/core'; import {TestBed} from '@angular/core/testing'; diff --git a/core/templates/pages/exploration-player-page/services/player-transcript.service.spec.ts b/core/templates/pages/exploration-player-page/services/player-transcript.service.spec.ts index a38144cade427..73906cd6547e9 100644 --- a/core/templates/pages/exploration-player-page/services/player-transcript.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/player-transcript.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the player transcript service. */ +// @ts-nocheck + import {TestBed} from '@angular/core/testing'; import {PlayerTranscriptService} from './player-transcript.service'; diff --git a/core/templates/pages/exploration-player-page/services/voiceover-player.service.spec.ts b/core/templates/pages/exploration-player-page/services/voiceover-player.service.spec.ts index bfc3c4deb316f..dc9b454bbaa91 100644 --- a/core/templates/pages/exploration-player-page/services/voiceover-player.service.spec.ts +++ b/core/templates/pages/exploration-player-page/services/voiceover-player.service.spec.ts @@ -17,6 +17,8 @@ * entity in a given language accent code. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {TestBed} from '@angular/core/testing'; import {EntityVoiceovers} from '../../../domain/voiceover/entity-voiceovers.model'; diff --git a/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.css b/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.css new file mode 100644 index 0000000000000..9094798b29c2d --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.css @@ -0,0 +1,77 @@ +.oppia-add-a-follow-up-note-modal .modal-title { + color: #00645c; + font-size: 18px; + font-weight: 700; +} + +.oppia-follow-up-modal-helper { + color: #5f6368; + font-size: 14px; + line-height: 20px; + margin: 0 0 16px; +} + +.oppia-follow-up-modal-original { + background: #f8f9fa; + border-left: 3px solid #d5d8dc; + border-radius: 4px; + margin-bottom: 16px; + padding: 10px 12px; +} + +.oppia-follow-up-modal-original-label { + color: #5f6368; + display: block; + font-size: 12px; + font-weight: 600; + letter-spacing: .03em; + margin-bottom: 4px; + text-transform: uppercase; +} + +.oppia-follow-up-modal-original-text { + -webkit-box-orient: vertical; + color: #333; + display: -webkit-box; + font-size: 14px; + -webkit-line-clamp: 3; + line-height: 20px; + margin: 0; + overflow: hidden; + overflow-wrap: anywhere; +} + +.oppia-follow-up-modal-label { + color: #333; + display: block; + font-size: 14px; + font-weight: 600; + margin-bottom: 6px; +} + +.oppia-my-suggestions-textarea { + border: 1px solid #bdbdbd; + border-radius: 4px; + display: block; + margin-bottom: 0; + padding: 10px; + resize: vertical; + width: 100%; +} + +.oppia-my-suggestions-textarea:focus { + border-color: #00645c; + outline: none; +} + +.oppia-my-suggestions-textarea:disabled { + background: #f5f5f5; + cursor: default; +} + +.oppia-follow-up-modal-char-count { + color: #9aa0a6; + font-size: 12px; + margin-top: 4px; + text-align: right; +} diff --git a/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.html b/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.html new file mode 100644 index 0000000000000..3e946ab0ca209 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.html @@ -0,0 +1,53 @@ +
+ + +
diff --git a/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.spec.ts b/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.spec.ts new file mode 100644 index 0000000000000..73ada8827cfb9 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.spec.ts @@ -0,0 +1,227 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Unit tests for add a follow up note modal. + */ + +import {HttpClientTestingModule} from '@angular/common/http/testing'; +import {FormsModule} from '@angular/forms'; +import {MockTranslatePipe} from 'tests/unit-test-utils'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import { + MAT_BOTTOM_SHEET_DATA, + MatBottomSheetRef, +} from '@angular/material/bottom-sheet'; +import { + ComponentFixture, + fakeAsync, + TestBed, + waitForAsync, + tick, +} from '@angular/core/testing'; +import {AddAFollowUpNoteModalComponent} from './add-a-follow-up-note-modal.component'; +import {AlertsService} from 'services/alerts.service'; +import {FeedbackBackendApiService} from 'domain/feedback/feedback-backend-api.service'; +import { + FeedbackStatus, + LessonFeedbackDetailResponse, +} from 'domain/feedback/feedback.model'; + +describe('AddAFollowUpNoteModalComponent', () => { + let component: AddAFollowUpNoteModalComponent; + let fixture: ComponentFixture; + let alertsService: AlertsService; + let feedbackBackendApiService: FeedbackBackendApiService; + let ngbActiveModal: NgbActiveModal; + let detailFeedback: LessonFeedbackDetailResponse; + let feedbackBackendApiSpy: jasmine.Spy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, FormsModule], + declarations: [AddAFollowUpNoteModalComponent, MockTranslatePipe], + providers: [AlertsService, FeedbackBackendApiService, NgbActiveModal], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AddAFollowUpNoteModalComponent); + component = fixture.componentInstance; + alertsService = TestBed.inject(AlertsService); + feedbackBackendApiService = TestBed.inject(FeedbackBackendApiService); + ngbActiveModal = TestBed.inject(NgbActiveModal); + + feedbackBackendApiSpy = spyOn( + feedbackBackendApiService, + 'submitMyFeedbackFollowUpAsync' + ); + spyOn(alertsService, 'addSuccessMessage'); + spyOn(alertsService, 'addWarning'); + spyOn(ngbActiveModal, 'dismiss').and.callThrough(); + + detailFeedback = { + id: 'test_report_id', + feedback_text: 'Test report', + status: FeedbackStatus.OPEN, + lesson_metadata: { + exploration_id: 'test', + exploration_version: 1, + state_name: 'intro', + state_index: 1, + learner_current_answer: 'test', + }, + parent_feedback_id: null, + response_list: [], + unread_response_count: 0, + created_on_msecs: 123456, + }; + + component.detailFeedback = detailFeedback; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.detailFeedback).toEqual(detailFeedback); + expect(component.followUpText).toEqual(''); + }); + + it('should submit successfully', fakeAsync(() => { + feedbackBackendApiSpy.and.returnValue( + Promise.resolve({ + success: true, + }) + ); + component.followUpText = 'test'; + component.submit(); + tick(); + + expect( + feedbackBackendApiService.submitMyFeedbackFollowUpAsync + ).toHaveBeenCalled(); + expect(alertsService.addSuccessMessage).toHaveBeenCalledWith( + 'Your follow up note has been sent successfully', + 7000, + true + ); + expect(ngbActiveModal.dismiss).toHaveBeenCalled(); + expect(component.followUpText).toEqual(''); + expect(component.isSubmittingFollowUp).toEqual(false); + })); + + it('should fail to submit', fakeAsync(() => { + feedbackBackendApiSpy.and.returnValue(Promise.reject()); + component.followUpText = 'test'; + component.submit(); + tick(); + + expect( + feedbackBackendApiService.submitMyFeedbackFollowUpAsync + ).toHaveBeenCalled(); + expect(alertsService.addWarning).toHaveBeenCalledWith( + 'Your follow up note has not been sent successfully' + ); + expect(ngbActiveModal.dismiss).not.toHaveBeenCalled(); + expect(component.followUpText).toEqual('test'); + expect(component.isSubmittingFollowUp).toEqual(false); + })); + + it('should dismiss modal', () => { + component.followUpText = 'test'; + component.closemodal(); + expect(ngbActiveModal.dismiss).toHaveBeenCalled(); + expect(component.followUpText).toEqual(''); + expect(component.isSubmittingFollowUp).toEqual(false); + }); +}); + +describe('AddAFollowUpNoteModalComponent opened as a bottom sheet', () => { + let component: AddAFollowUpNoteModalComponent; + let fixture: ComponentFixture; + let alertsService: AlertsService; + let feedbackBackendApiService: FeedbackBackendApiService; + let ngbActiveModal: NgbActiveModal; + let bottomSheetDismissSpy: jasmine.Spy; + let detailFeedback: LessonFeedbackDetailResponse; + + beforeEach(waitForAsync(() => { + detailFeedback = { + id: 'test_report_id', + feedback_text: 'Test report', + status: FeedbackStatus.OPEN, + lesson_metadata: { + exploration_id: 'test', + exploration_version: 1, + state_name: 'intro', + state_index: 1, + learner_current_answer: 'test', + }, + parent_feedback_id: null, + response_list: [], + unread_response_count: 0, + created_on_msecs: 123456, + }; + const mockBottomSheetRef = {dismiss: () => {}}; + bottomSheetDismissSpy = spyOn(mockBottomSheetRef, 'dismiss'); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, FormsModule], + declarations: [AddAFollowUpNoteModalComponent, MockTranslatePipe], + providers: [ + AlertsService, + FeedbackBackendApiService, + NgbActiveModal, + {provide: MatBottomSheetRef, useValue: mockBottomSheetRef}, + {provide: MAT_BOTTOM_SHEET_DATA, useValue: {detailFeedback}}, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AddAFollowUpNoteModalComponent); + component = fixture.componentInstance; + alertsService = TestBed.inject(AlertsService); + feedbackBackendApiService = TestBed.inject(FeedbackBackendApiService); + ngbActiveModal = TestBed.inject(NgbActiveModal); + spyOn(alertsService, 'addSuccessMessage'); + spyOn(ngbActiveModal, 'dismiss'); + // No input binding is set here, so the detail feedback must come from + // the injected bottom sheet data. + fixture.detectChanges(); + }); + + it('should read the detail feedback from the bottom sheet data', () => { + expect(component.detailFeedback).toEqual(detailFeedback); + }); + + it('should dismiss the bottom sheet after a successful submission', fakeAsync(() => { + spyOn( + feedbackBackendApiService, + 'submitMyFeedbackFollowUpAsync' + ).and.returnValue( + Promise.resolve({ + success: true, + }) + ); + component.followUpText = 'test'; + component.submit(); + tick(); + + expect( + feedbackBackendApiService.submitMyFeedbackFollowUpAsync + ).toHaveBeenCalled(); + expect(bottomSheetDismissSpy).toHaveBeenCalled(); + expect(ngbActiveModal.dismiss).not.toHaveBeenCalled(); + })); +}); diff --git a/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.ts b/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.ts new file mode 100644 index 0000000000000..c1bf7b907214b --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/add-a-follow-up-note-modal/add-a-follow-up-note-modal.component.ts @@ -0,0 +1,92 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Component for add a follow up note modal. + */ +import {Component, Inject, Input, OnInit, Optional} from '@angular/core'; +import { + MAT_BOTTOM_SHEET_DATA, + MatBottomSheetRef, +} from '@angular/material/bottom-sheet'; +import {AlertsService} from 'services/alerts.service'; +import {FeedbackBackendApiService} from 'domain/feedback/feedback-backend-api.service'; +import {LessonFeedbackDetailResponse} from 'domain/feedback/feedback.model'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import './add-a-follow-up-note-modal.component.css'; + +@Component({ + selector: 'oppia-add-a-follow-up-note-modal', + templateUrl: './add-a-follow-up-note-modal.component.html', + styleUrls: ['./add-a-follow-up-note-modal.component.css'], +}) +export class AddAFollowUpNoteModalComponent implements OnInit { + @Input() detailFeedback!: LessonFeedbackDetailResponse; + + followUpText: string = ''; + isSubmittingFollowUp: boolean = false; + + constructor( + private alertService: AlertsService, + private feedbackBackendApiService: FeedbackBackendApiService, + @Optional() private ngbActiveModal: NgbActiveModal, + @Optional() + private bottomSheetRef?: MatBottomSheetRef, + @Optional() + @Inject(MAT_BOTTOM_SHEET_DATA) + private bottomSheetData?: {detailFeedback: LessonFeedbackDetailResponse} + ) {} + + ngOnInit(): void { + // When opened as a bottom sheet, the detail feedback arrives through + // MAT_BOTTOM_SHEET_DATA instead of an input binding. + if (!this.detailFeedback && this.bottomSheetData) { + this.detailFeedback = this.bottomSheetData.detailFeedback; + } + } + + closemodal(): void { + this.followUpText = ''; + this.isSubmittingFollowUp = false; + if (this.bottomSheetRef) { + this.bottomSheetRef.dismiss(); + return; + } + this.ngbActiveModal.dismiss(); + } + + async submit(): Promise { + this.isSubmittingFollowUp = true; + + try { + await this.feedbackBackendApiService.submitMyFeedbackFollowUpAsync( + this.detailFeedback.id, + this.followUpText + ); + + this.alertService.addSuccessMessage( + 'Your follow up note has been sent successfully', + 7000, + true + ); + this.closemodal(); + } catch (error) { + this.alertService.addWarning( + 'Your follow up note has not been sent successfully' + ); + } finally { + this.isSubmittingFollowUp = false; + } + } +} diff --git a/core/templates/pages/learner-dashboard-page/goals-tab.component.spec.ts b/core/templates/pages/learner-dashboard-page/goals-tab.component.spec.ts index 38c433a56e7db..c94d10c492069 100644 --- a/core/templates/pages/learner-dashboard-page/goals-tab.component.spec.ts +++ b/core/templates/pages/learner-dashboard-page/goals-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for for GoalsTabComponent. */ +// @ts-nocheck + import { waitForAsync, ComponentFixture, diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css index ceaa7b8671462..b6320b5b9db78 100644 --- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css +++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css @@ -95,6 +95,22 @@ oppia-learner-dashboard-page .oppia-svg-image { color: #00645c; } +.oppia-learner-dashboard-sidebar-notification-badge { + align-items: center; + background: #c92820; + border-radius: 999px; + color: #fff; + display: inline-flex; + font-size: 12px; + font-weight: 700; + justify-content: center; + line-height: 1; + margin-left: auto; + min-height: 20px; + min-width: 20px; + padding: 4px 6px; +} + .oppia-learner-dashborad-menu-hover:hover { background-color: rgba(0, 100, 92, 0.1); } diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html index 5fb20573f1ab2..c9c8ce34071c9 100644 --- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html +++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html @@ -93,6 +93,22 @@ {{ LEARNER_DASHBOARD_SECTION_I18N_IDS.MY_CERTIFICATES | translate }} +
  • + +
  • @@ -195,6 +211,11 @@

    +
    + + +
    { let urlService: UrlService; let platformFeatureService: PlatformFeatureService; let learnerDashboardBackendApiServiceSpy: jasmine.Spy; + let feedbackBackendApiService: FeedbackBackendApiService; let titleList = [ 'World War III', @@ -334,6 +345,7 @@ describe('Learner dashboard page', () => { learnerGroupBackendApiService = TestBed.inject( LearnerGroupBackendApiService ); + feedbackBackendApiService = TestBed.inject(FeedbackBackendApiService); platformFeatureService = TestBed.inject(PlatformFeatureService); const mockElement = document.createElement('div'); @@ -483,6 +495,22 @@ describe('Learner dashboard page', () => { 'isLearnerGroupFeatureEnabledAsync' ).and.returnValue(Promise.resolve(true)); + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackListAsync' + ).and.returnValue( + Promise.resolve({ + summaries: [], + next_cursor: null, + more: false, + }) + ); + + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackUnreadCountAsync' + ).and.returnValue(Promise.resolve(0)); + spyOn(urlService, 'getUrlParams').and.returnValue({ active_tab: 'learner-groups', }); @@ -1026,6 +1054,99 @@ describe('Learner dashboard page', () => { sample_topic_id: {1: 1}, }); })); + + it('should fetch the backend unread total for my suggestions', fakeAsync(() => { + ( + feedbackBackendApiService.fetchMyFeedbackUnreadCountAsync as jasmine.Spy + ).and.returnValue(Promise.resolve(6)); + + component.fetchUnreadMySuggestionsCount(); + flush(); + + expect( + feedbackBackendApiService.fetchMyFeedbackUnreadCountAsync + ).toHaveBeenCalled(); + expect(component.unreadMySuggestionsCount).toBe(6); + })); + + it('should count unread feedback on later pages via the backend total', fakeAsync(() => { + // The first page of the paginated list has no unread responses, but + // unread feedback exists on later pages. The indicator must reflect + // the backend-provided global total instead of the first-page sum. + const firstPageSummaries: LessonFeedbackSummary[] = [ + { + id: 'feedback-1', + feedback_text_preview: 'First feedback', + latest_response_preview: '', + status: FeedbackStatus.OPEN, + lesson_title: 'Lesson 1', + source: 'lesson', + unread_response_count: 0, + last_updated_msecs: 100, + }, + ]; + ( + feedbackBackendApiService.fetchMyFeedbackListAsync as jasmine.Spy + ).and.returnValue( + Promise.resolve({ + summaries: firstPageSummaries, + next_cursor: 'cursor-2', + more: true, + }) + ); + ( + feedbackBackendApiService.fetchMyFeedbackUnreadCountAsync as jasmine.Spy + ).and.returnValue(Promise.resolve(4)); + + component.fetchUnreadMySuggestionsCount(); + flush(); + + expect(component.unreadMySuggestionsCount).toBe(4); + })); + + it('should set active section to my suggestions when my suggestions tab is active', fakeAsync(() => { + spyOnProperty(platformFeatureService, 'status', 'get').and.returnValue({ + ShowRedesignedLearnerDashboard: { + isEnabled: true, + }, + EnableCertificateAssessment: { + isEnabled: false, + }, + ExplorationEditorNewCreatorFeedbackTab: { + isEnabled: true, + }, + }); + + (urlService.getUrlParams as jasmine.Spy).and.returnValue({ + active_tab: 'my-suggestions', + }); + + component.ngOnInit(); + flush(); + + expect(component.activeSection).toBe( + component.LEARNER_DASHBOARD_SECTION_I18N_IDS.MY_SUGGESTIONS + ); + })); + + it('should change the unreadMySuggestionsCount value', () => { + component.unreadMySuggestionsCount = 0; + component.onMySuggestionsUnreadCountChanged(1); + expect(component.unreadMySuggestionsCount).toBe(1); + }); + + it('should set unreadMySuggestionsCount to zero when fetching the unread total fails', fakeAsync(() => { + component.unreadMySuggestionsCount = 5; + + ( + feedbackBackendApiService.fetchMyFeedbackUnreadCountAsync as jasmine.Spy + ).and.returnValue(Promise.reject()); + + component.fetchUnreadMySuggestionsCount(); + flush(); + + expect(component.unreadMySuggestionsCount).toBe(0); + })); }); describe('when fetching dashboard data fails', () => { @@ -1414,6 +1535,9 @@ describe('Learner dashboard page', () => { ShowRedesignedLearnerDashboard: { isEnabled: true, }, + ExplorationEditorNewCreatorFeedbackTab: { + isEnabled: false, + }, }); component.setActiveSection( @@ -1426,6 +1550,29 @@ describe('Learner dashboard page', () => { ); }); + it('should return suggestions greeting when my suggestions tab is active and exploration new creator feedback tab is enabled', () => { + spyOnProperty(platformFeatureService, 'status', 'get').and.returnValue({ + EnableCertificateAssessment: { + isEnabled: false, + }, + ShowRedesignedLearnerDashboard: { + isEnabled: true, + }, + ExplorationEditorNewCreatorFeedbackTab: { + isEnabled: true, + }, + }); + + component.setActiveSection( + 'I18N_LEARNER_DASHBOARD_MY_SUGGESTIONS_SECTION' + ); + fixture.detectChanges(); + + expect(component.getDashboardTabHeading()).toBe( + 'I18N_LEARNER_DASHBOARD_MY_SUGGESTIONS_SECTION_HEADING' + ); + }); + it('should keep the current tab when my certificates is disabled', () => { spyOnProperty(platformFeatureService, 'status', 'get').and.returnValue({ ShowRedesignedLearnerDashboard: { @@ -1434,6 +1581,9 @@ describe('Learner dashboard page', () => { EnableCertificateAssessment: { isEnabled: false, }, + ExplorationEditorNewCreatorFeedbackTab: { + isEnabled: false, + }, }); component.activeSection = @@ -1448,6 +1598,31 @@ describe('Learner dashboard page', () => { ); }); + it('should keep the current tab when my suggestions is disabled', () => { + spyOnProperty(platformFeatureService, 'status', 'get').and.returnValue({ + ShowRedesignedLearnerDashboard: { + isEnabled: true, + }, + EnableCertificateAssessment: { + isEnabled: false, + }, + ExplorationEditorNewCreatorFeedbackTab: { + isEnabled: false, + }, + }); + + component.activeSection = + component.LEARNER_DASHBOARD_SECTION_I18N_IDS.HOME; + + component.setActiveSection( + 'I18N_LEARNER_DASHBOARD_MY_SUGGESTIONS_SECTION' + ); + + expect(component.activeSection).toBe( + component.LEARNER_DASHBOARD_SECTION_I18N_IDS.HOME + ); + }); + it('should return default greeting when current tab is not valid', () => { component.activeSection = 'I18N_LEARNER_DASHBOARD_PLAYLIST_SECTION'; fixture.detectChanges(); diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts index 9096fa90a8435..6f65092fa2aee 100644 --- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts +++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts @@ -53,6 +53,7 @@ import {PageTitleService} from 'services/page-title.service'; import {LearnerGroupBackendApiService} from 'domain/learner_group/learner-group-backend-api.service'; import {UrlService} from 'services/contextual/url.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import {FeedbackBackendApiService} from 'domain/feedback/feedback-backend-api.service'; import './learner-dashboard-page.component.css'; interface LearnerDashboardExplorationsData { @@ -181,6 +182,7 @@ export class LearnerDashboardPageComponent implements OnInit, OnDestroy { LEARNER_GROUP_FEATURE_IS_ENABLED: boolean = false; subtopicMasteries: Record = {}; curatedExplorationIds = new Set(); + unreadMySuggestionsCount: number = 0; constructor( private alertsService: AlertsService, @@ -198,7 +200,8 @@ export class LearnerDashboardPageComponent implements OnInit, OnDestroy { private pageTitleService: PageTitleService, private learnerGroupBackendApiService: LearnerGroupBackendApiService, private urlService: UrlService, - private platFeatService: PlatformFeatureService + private platFeatService: PlatformFeatureService, + private feedbackBackendApiService: FeedbackBackendApiService ) {} populateCuratedExplorationIds(): void { @@ -284,6 +287,14 @@ export class LearnerDashboardPageComponent implements OnInit, OnDestroy { this.activeSection = LearnerDashboardPageConstants.LEARNER_DASHBOARD_SECTION_I18N_IDS.LEARNER_GROUPS; } + if ( + this.isShowRedesignedLearnerDashboardActive() && + this.isNewExplorationEditorFeedbackTabEnabled() && + this.urlService.getUrlParams().active_tab === 'my-suggestions' + ) { + this.activeSection = + LearnerDashboardPageConstants.LEARNER_DASHBOARD_SECTION_I18N_IDS.MY_SUGGESTIONS; + } return this.getSubtopicMasteryData(); }, @@ -303,6 +314,9 @@ export class LearnerDashboardPageComponent implements OnInit, OnDestroy { learnerGroupFeatureIsEnabledPromise.then(featureIsEnabled => { this.LEARNER_GROUP_FEATURE_IS_ENABLED = featureIsEnabled; }); + if (this.isNewExplorationEditorFeedbackTabEnabled()) { + this.fetchUnreadMySuggestionsCount(); + } let dashboardCollectionsDataPromise = this.learnerDashboardBackendApiService.fetchLearnerDashboardCollectionsDataAsync(); @@ -409,6 +423,14 @@ export class LearnerDashboardPageComponent implements OnInit, OnDestroy { ) { return; } + if ( + newActiveSectionName === + LearnerDashboardPageConstants.LEARNER_DASHBOARD_SECTION_I18N_IDS + .MY_SUGGESTIONS && + !this.isNewExplorationEditorFeedbackTabEnabled() + ) { + return; + } this.activeSection = newActiveSectionName; if ( this.activeSection === @@ -586,6 +608,11 @@ export class LearnerDashboardPageComponent implements OnInit, OnDestroy { return this.platFeatService.status.EnableCertificateAssessment.isEnabled; } + isNewExplorationEditorFeedbackTabEnabled(): boolean { + return this.platFeatService.status.ExplorationEditorNewCreatorFeedbackTab + .isEnabled; + } + getDashboardTabHeading(): string { switch (this.activeSection) { case LearnerDashboardPageConstants.LEARNER_DASHBOARD_SECTION_I18N_IDS @@ -600,6 +627,9 @@ export class LearnerDashboardPageComponent implements OnInit, OnDestroy { case LearnerDashboardPageConstants.LEARNER_DASHBOARD_SECTION_I18N_IDS .MY_CERTIFICATES: return 'I18N_LEARNER_DASHBOARD_MY_CERTIFICATES_SECTION_HEADING'; + case LearnerDashboardPageConstants.LEARNER_DASHBOARD_SECTION_I18N_IDS + .MY_SUGGESTIONS: + return 'I18N_LEARNER_DASHBOARD_MY_SUGGESTIONS_SECTION_HEADING'; default: return `No valid I18N key for heading of ${this.activeSection}`; } @@ -612,4 +642,19 @@ export class LearnerDashboardPageComponent implements OnInit, OnDestroy { ...this.learntTopicsList.map(topic => topic.id), ]); } + + fetchUnreadMySuggestionsCount(): void { + this.feedbackBackendApiService + .fetchMyFeedbackUnreadCountAsync() + .then(unreadCount => { + this.unreadMySuggestionsCount = unreadCount; + }) + .catch(() => { + this.unreadMySuggestionsCount = 0; + }); + } + + onMySuggestionsUnreadCountChanged(unreadCount: number): void { + this.unreadMySuggestionsCount = unreadCount; + } } diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.constants.ts b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.constants.ts index ff968ede9f081..5f8f10cc3fafb 100644 --- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.constants.ts +++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.constants.ts @@ -28,6 +28,7 @@ export const LearnerDashboardPageConstants = { LEARNER_GROUPS: 'I18N_LEARNER_DASHBOARD_LEARNER_GROUPS_SECTION', COMMUNITY_LESSONS: 'I18N_LEARNER_DASHBOARD_COMMUNITY_LESSONS_SECTION', MY_CERTIFICATES: 'I18N_LEARNER_DASHBOARD_MY_CERTIFICATES_SECTION', + MY_SUGGESTIONS: 'I18N_LEARNER_DASHBOARD_MY_SUGGESTIONS_SECTION', }, LEARNER_DASHBOARD_SUBSECTION_I18N_IDS: { diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.module.ts b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.module.ts index c2f6c2fe3bab2..59dd7433fe109 100644 --- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.module.ts +++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.module.ts @@ -20,7 +20,7 @@ import {NgModule} from '@angular/core'; import {SharedComponentsModule} from 'components/shared-component.module'; import {RouterModule} from '@angular/router'; import {CommonModule} from '@angular/common'; - +import {FeedbackSharedModule} from 'components/feedback-shared/feedback-shared.module'; import {LessonCardModule} from 'components/summary-tile/lesson-card.module'; import {CardDisplayComponent} from './card-display/card-display.component'; import {ClassroomButtonComponent} from './classroom-button/classroom-button.component'; @@ -47,14 +47,20 @@ import {ToastrModule} from 'ngx-toastr'; import {LearnerDashboardActivityBackendApiService} from 'domain/learner_dashboard/learner-dashboard-activity-backend-api.service'; import {AddGoalsModalComponent} from './add-goals-modal/add-goals-modal.component'; import {MatDialogModule} from '@angular/material/dialog'; +import {MatBottomSheetModule} from '@angular/material/bottom-sheet'; import {NgCircleProgressModule} from 'ng-circle-progress'; import {MyCertificatesTabComponent} from './my-certificates-tab.component'; +import {MySuggestionsTabComponent} from './my-suggestions-tab.component'; +import {AddAFollowUpNoteModalComponent} from './add-a-follow-up-note-modal/add-a-follow-up-note-modal.component'; + @NgModule({ imports: [ CommonModule, SharedComponentsModule, + FeedbackSharedModule, LessonCardModule, MatDialogModule, + MatBottomSheetModule, SkillCardModule, ToastrModule.forRoot(toastrConfig), RouterModule.forChild([ @@ -98,6 +104,8 @@ import {MyCertificatesTabComponent} from './my-certificates-tab.component'; ViewLearnerGroupInvitationModalComponent, ViewLearnerGroupDetailsModalComponent, MyCertificatesTabComponent, + MySuggestionsTabComponent, + AddAFollowUpNoteModalComponent, ], entryComponents: [ AddGoalsModalComponent, @@ -120,6 +128,8 @@ import {MyCertificatesTabComponent} from './my-certificates-tab.component'; ViewLearnerGroupInvitationModalComponent, ViewLearnerGroupDetailsModalComponent, MyCertificatesTabComponent, + MySuggestionsTabComponent, + AddAFollowUpNoteModalComponent, ], providers: [LearnerDashboardActivityBackendApiService], }) diff --git a/core/templates/pages/learner-dashboard-page/my-certificate-tab.component.spec.ts b/core/templates/pages/learner-dashboard-page/my-certificate-tab.component.spec.ts index 6a4f9b0d33878..38b0915f95685 100644 --- a/core/templates/pages/learner-dashboard-page/my-certificate-tab.component.spec.ts +++ b/core/templates/pages/learner-dashboard-page/my-certificate-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for MyCertificatesTabComponent. */ +// @ts-nocheck + import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {CommonModule} from '@angular/common'; diff --git a/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.css b/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.css new file mode 100644 index 0000000000000..211e5e6be2e12 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.css @@ -0,0 +1,231 @@ +.oppia-my-suggestions-tab { + background-color: #fff; + border-radius: 4px; + box-shadow: 0 0 4px #0000001a; + display: inline-grid; + min-height: calc(100vh - 150px); + padding: 16px; + width: 100%; +} + +.oppia-feedback-table { + display: flex; + height: 60vh; +} + +.oppia-my-suggestions-detail { + display: block; +} + + +.oppia-my-suggestions-detail-actions { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 20px; +} + +.oppia-my-suggestions-back { + align-items: center; + background: #00796b; + border: 0; + border-radius: 4px; + color: #fff; + cursor: pointer; + display: inline-flex; + font-size: 16px; + font-weight: 500; + gap: 8px; + padding: 8px 16px; + transition: background .15s ease; +} + +.oppia-my-suggestions-back:hover { + background: #00645c; +} + +.oppia-my-suggestions-follow-up-button { + background: #00796b; + border: 0; + border-radius: 4px; + color: #fff; + cursor: pointer; + font-size: 14px; + font-weight: 500; + min-height: 36px; + min-width: 188px; + padding: 8px 16px; + transition: background .15s ease; +} + + +.oppia-my-suggestions-follow-up-button:disabled { + background: #9aa0a6; + cursor: default; +} + +.oppia-my-suggestions-follow-up-button:hover:not(:disabled) { + background: #00645c; +} + +.oppia-my-suggestions-lesson-context { + align-items: center; + background: rgba(0, 100, 92, 0.05); + border-radius: 4px; + color: #333; + display: flex; + font-size: 14px; + gap: 10px; + margin-bottom: 12px; + padding: 12px 14px; +} + +.oppia-my-suggestions-lesson-context i { + color: #00645c; + flex: 0 0 auto; +} + +.oppia-my-suggestions-lesson-context a { + color: #00645c; + font-weight: 600; + text-decoration: underline; +} + + +.oppia-my-suggestions-detail-header { + align-items: center; + background: #fff; + border: 1px solid #e6e8eb; + border-radius: 4px; + display: flex; + justify-content: space-between; + margin-bottom: 16px; + padding: 14px 16px; +} + +.oppia-my-suggestions-meta-item { + align-items: center; + display: flex; + gap: 10px; +} + +.oppia-my-suggestions-meta-item-date { + flex-direction: row; +} + +.oppia-my-suggestions-meta-label { + color: black; + font-size: 13px; +} + +.oppia-my-suggestions-date { + color: #333; + font-size: 14px; + font-weight: 600; + line-height: 18px; +} + + + +.oppia-my-suggestions-row-status { + border-radius: 999px; + display: inline-block; + font-size: 14px; + font-weight: 600; + padding: 6px 14px; + white-space: nowrap; +} + +.oppia-my-suggestions-status-submitted { + background: #ffc107; + color: #212529; +} + +.oppia-my-suggestions-status-reviewed { + background: #f1f3f4; + color: #5f6368; +} + +.oppia-my-suggestions-status-fixed { + background: rgba(0, 100, 92, 0.14); + color: #00645c; +} + +.oppia-my-suggestions-thread { + background: #fff; + border: 1px solid #e6e8eb; + border-radius: 4px; + padding: 0 20px; +} + +.oppia-my-suggestions-thread-item { + display: flex; + gap: 16px; + padding: 20px 0; +} + +.oppia-my-suggestions-thread-item + .oppia-my-suggestions-thread-item { + border-top: 1px solid #eef0f1; +} + +.oppia-my-suggestions-avatar { + align-items: center; + border-radius: 50%; + display: flex; + flex: 0 0 auto; + font-size: 14px; + font-weight: 600; + height: 40px; + justify-content: center; + width: 40px; +} + +.oppia-my-suggestions-avatar-you { + background: #f1f3f4; + color: #5f6368; +} + +.oppia-my-suggestions-avatar-team { + background: rgba(0, 100, 92, 0.1); + color: #00645c; +} + +.oppia-my-suggestions-thread-content { + flex: 1; + min-width: 0; +} + +.oppia-my-suggestions-thread-meta { + align-items: baseline; + display: flex; + gap: 16px; + justify-content: space-between; + margin-bottom: 4px; +} + +.oppia-my-suggestions-thread-author { + color: #333; + font-size: 16px; + font-weight: 600; +} + +.oppia-my-suggestions-thread-item-creator .oppia-my-suggestions-thread-author { + color: #00645c; +} + +.oppia-my-suggestions-thread-date { + color: #5f6368; + flex: 0 0 auto; + font-size: 14px; + line-height: 18px; + max-width: 140px; +} + +.oppia-my-suggestions-thread-content p { + color: #333; + font-size: 16px; + line-height: 22px; + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; +} diff --git a/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.html b/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.html new file mode 100644 index 0000000000000..c389388116857 --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.html @@ -0,0 +1,111 @@ +
    + + + + +
    + + + + + +
    +
    +
    + + + +
    + + +
    + + + {{ 'I18N_MY_SUGGESTION_LESSON_METADATA_TEXT_START' | translate }} + + {{ 'I18N_MY_SUGGESTION_LESSON_METADATA_TEXT_MIDDLE' | translate }} + , + {{ getLessonStepDescription(lesson.state_name)}}. + +
    + +
    +
    + + {{'I18N_MY_SUGGESTION_STATUS_LABEL' | translate }} + + + {{ getStatusDetails(selectedFeedback.status).label }} + +
    +
    + {{ 'I18N_MY_SUGGESTION_SUBMITTED_ON'| translate }} + + {{ formatDate(selectedFeedback.created_on_msecs) }} + +
    +
    + +
    +
    + + Y + +
    +
    + + {{ 'I18N_MY_SUGGESTION_LEARNER_THREAD_HEADER' | translate }} + + + {{ formatDate(selectedFeedback.created_on_msecs) }} + +
    +

    {{ selectedFeedback.feedback_text }}

    +
    +
    + +
    + + + +
    +
    + + {{ 'I18N_MY_SUGGESTION_CREATOR_THREAD_HEADER' | translate }} + + + {{ formatDate(response.responded_on) }} + +
    +

    {{ response.response_text }}

    +
    +
    +
    +
    +
    +
    +
    diff --git a/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.spec.ts b/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.spec.ts new file mode 100644 index 0000000000000..5dc1eab181cec --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.spec.ts @@ -0,0 +1,681 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +/** + * @fileoverview Unit tests for My Suggestions tab in learner dashboard. + */ + +import {HttpClientTestingModule} from '@angular/common/http/testing'; +import { + ComponentFixture, + TestBed, + fakeAsync, + flushMicrotasks, + waitForAsync, +} from '@angular/core/testing'; +import {MockTranslatePipe} from 'tests/unit-test-utils'; +import {MatBottomSheet} from '@angular/material/bottom-sheet'; +import {MatBottomSheetModule} from '@angular/material/bottom-sheet'; +import {MySuggestionsTabComponent} from './my-suggestions-tab.component'; +import {AddAFollowUpNoteModalComponent} from './add-a-follow-up-note-modal/add-a-follow-up-note-modal.component'; +import {AlertsService} from 'services/alerts.service'; +import {DateTimeFormatService} from 'services/date-time-format.service'; +import {UrlService} from 'services/contextual/url.service'; +import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; +import {WindowRef} from 'services/contextual/window-ref.service'; +import {LoaderService} from 'services/loader.service'; +import {FeedbackBackendApiService} from 'domain/feedback/feedback-backend-api.service'; +import {RouterTestingModule} from '@angular/router/testing'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {BaseModule} from '../../base-components/base.module'; +import {FeedbackSharedModule} from 'components/feedback-shared/feedback-shared.module'; +import { + CreatorFeedbackType, + FeedbackStatus, + LessonFeedbackDetailResponse, + LessonFeedbackSummary, + ReportType, + TechnicalTeamType, +} from '../../domain/feedback/feedback.model'; + +const mockLessonFeedbackSummary: LessonFeedbackSummary = { + id: 'lesson_feedback_1', + feedback_text_preview: 'Lesson feedback', + latest_response_preview: '', + status: FeedbackStatus.OPEN, + source: ReportType.LESSON, + lesson_title: 'exp_1', + unread_response_count: 0, + last_updated_msecs: 12345, +}; + +const mockLessonFeedbackDetailResponse: LessonFeedbackDetailResponse = { + id: 'feedback_id', + feedback_text: 'feedback', + status: FeedbackStatus.OPEN, + lesson_metadata: { + exploration_id: 'exp_id', + exploration_version: 1, + state_name: 'Introduction', + state_index: 0, + learner_current_answer: null, + }, + parent_feedback_id: null, + response_list: [], + unread_response_count: 0, + created_on_msecs: 12345, +}; + +class MockNgbModal { + open() { + return { + result: Promise.resolve(), + componentInstance: {}, + }; + } +} + +describe('My Suggestions Tab Component', () => { + let component: MySuggestionsTabComponent; + let fixture: ComponentFixture; + let feedbackBackendApiService: FeedbackBackendApiService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [MySuggestionsTabComponent, MockTranslatePipe], + imports: [ + BaseModule, + FeedbackSharedModule, + MatBottomSheetModule, + HttpClientTestingModule, + RouterTestingModule, + ], + providers: [ + AlertsService, + DateTimeFormatService, + UrlService, + WindowRef, + LoaderService, + FeedbackBackendApiService, + { + provide: NgbModal, + useClass: MockNgbModal, + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MySuggestionsTabComponent); + component = fixture.componentInstance; + feedbackBackendApiService = TestBed.inject(FeedbackBackendApiService); + }); + + it('should create', () => { + expect(component).toBeDefined(); + }); + + it('should get displayed learner feedback summaries', () => { + component.learnerLessonFeedbackListState.displayedSummaries = [ + mockLessonFeedbackSummary, + ]; + expect(component.getDisplayedLearnerFeedbackSummaries()).toEqual([ + mockLessonFeedbackSummary, + ]); + }); + + it('should get current page number of learner feedback list view', () => { + component.learnerLessonFeedbackListState.currentPage = 1; + expect(component.getLearnerFeedbackListCurrentPage()).toEqual(1); + }); + + it('should return true if learner feedback list has more than one page', () => { + component.learnerLessonFeedbackListState.moreAvailable = true; + expect(component.getLearnerFeedbackListMoreAvailable()).toEqual(true); + }); + + it('should sync the shared unread count when reading feedback on a later page', fakeAsync(() => { + // The unread entry lives on a later page of the paginated list, so the + // refreshed global total must come from the backend rather than from the + // summaries currently loaded. + const laterPageSummary: LessonFeedbackSummary = { + ...mockLessonFeedbackSummary, + id: 'feedback_id', + unread_response_count: 2, + }; + component.learnerLessonFeedbackListState.summaries = [laterPageSummary]; + component.learnerLessonFeedbackListState.displayedSummaries = [ + laterPageSummary, + ]; + + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackDetailAsync' + ).and.returnValue(Promise.resolve(mockLessonFeedbackDetailResponse)); + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackUnreadCountAsync' + ).and.returnValue(Promise.resolve(2)); + const emitSpy = spyOn(component.unreadCountChanged, 'emit'); + + component.openFeedbackDetail('feedback_id'); + flushMicrotasks(); + + expect( + feedbackBackendApiService.fetchMyFeedbackUnreadCountAsync + ).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(2); + expect( + component.learnerLessonFeedbackListState.summaries[0] + .unread_response_count + ).toBe(0); + expect( + component.learnerLessonFeedbackListState.displayedSummaries[0] + .unread_response_count + ).toBe(0); + })); + + it('should not change the shared unread count when refreshing it fails', fakeAsync(() => { + const laterPageSummary: LessonFeedbackSummary = { + ...mockLessonFeedbackSummary, + id: 'feedback_id', + unread_response_count: 2, + }; + component.learnerLessonFeedbackListState.summaries = [laterPageSummary]; + component.learnerLessonFeedbackListState.displayedSummaries = [ + laterPageSummary, + ]; + + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackDetailAsync' + ).and.returnValue(Promise.resolve(mockLessonFeedbackDetailResponse)); + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackUnreadCountAsync' + ).and.returnValue(Promise.reject()); + const emitSpy = spyOn(component.unreadCountChanged, 'emit'); + + component.openFeedbackDetail('feedback_id'); + flushMicrotasks(); + + expect(emitSpy).not.toHaveBeenCalled(); + })); + + it('should open the follow-up note modal on wide screens', () => { + const windowDimensionsService = TestBed.inject(WindowDimensionsService); + spyOn(windowDimensionsService, 'isWindowNarrow').and.returnValue(false); + const bottomSheet = TestBed.inject(MatBottomSheet); + const bottomSheetSpy = spyOn(bottomSheet, 'open'); + const ngbModal = TestBed.inject(NgbModal); + const ngbModalSpy = spyOn(ngbModal, 'open').and.callThrough(); + + component.openFollowUpModal(); + + expect(bottomSheetSpy).not.toHaveBeenCalled(); + expect(ngbModalSpy).toHaveBeenCalled(); + }); + + it('should open the follow-up note bottom sheet on narrow screens', () => { + const windowDimensionsService = TestBed.inject(WindowDimensionsService); + spyOn(windowDimensionsService, 'isWindowNarrow').and.returnValue(true); + const bottomSheet = TestBed.inject(MatBottomSheet); + const bottomSheetSpy = spyOn(bottomSheet, 'open'); + const ngbModal = TestBed.inject(NgbModal); + const ngbModalSpy = spyOn(ngbModal, 'open'); + + component.openFollowUpModal(); + + expect(bottomSheetSpy).toHaveBeenCalledWith( + AddAFollowUpNoteModalComponent, + { + data: {detailFeedback: null}, + } + ); + expect(ngbModalSpy).not.toHaveBeenCalled(); + }); + + it('should load and map the first feedback page on initialization', fakeAsync(() => { + const urlService = TestBed.inject(UrlService); + spyOn(urlService, 'getUrlParams').and.returnValue({}); + const loaderService = TestBed.inject(LoaderService); + const hideLoadingSpy = spyOn(loaderService, 'hideLoadingScreen'); + spyOn(loaderService, 'showLoadingScreen'); + spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ).and.returnValue( + Promise.resolve({ + summaries: [ + { + ...mockLessonFeedbackSummary, + id: 'fb_open', + status: FeedbackStatus.OPEN, + }, + { + ...mockLessonFeedbackSummary, + id: 'fb_fixed', + status: FeedbackStatus.FIXED, + }, + { + ...mockLessonFeedbackSummary, + id: 'fb_reviewed', + status: FeedbackStatus.COMPLIMENT, + }, + ], + next_cursor: 'cursor_2', + more: true, + }) + ); + + component.ngOnInit(); + flushMicrotasks(); + + expect(component.isLoading).toBeFalse(); + const state = component.learnerLessonFeedbackListState; + // Backend statuses are mapped to learner-facing statuses. + expect(state.summaries.map(summary => summary.status)).toEqual([ + FeedbackStatus.SUBMITTED, + FeedbackStatus.LESSON_UPDATED, + FeedbackStatus.REVIEWED_BY_TEAM, + ]); + expect(state.nextCursor).toBe('cursor_2'); + expect(state.moreAvailable).toBeTrue(); + // With no search text, every summary is displayed. + expect(state.displayedSummaries).toEqual(state.summaries); + expect(hideLoadingSpy).toHaveBeenCalled(); + })); + + it( + 'should open the detail view directly when a feedback id is present in' + + ' the url on initialization', + fakeAsync(() => { + const urlService = TestBed.inject(UrlService); + spyOn(urlService, 'getUrlParams').and.returnValue({ + feedback_id: 'feedback_url', + }); + const loaderService = TestBed.inject(LoaderService); + spyOn(loaderService, 'hideLoadingScreen'); + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackDetailAsync' + ).and.returnValue(Promise.resolve(mockLessonFeedbackDetailResponse)); + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackUnreadCountAsync' + ).and.returnValue(Promise.resolve(0)); + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ); + + component.ngOnInit(); + flushMicrotasks(); + + expect(listSpy).not.toHaveBeenCalled(); + expect(component.selectedFeedbackId).toBe('feedback_url'); + expect(component.selectedFeedback).toEqual( + mockLessonFeedbackDetailResponse + ); + expect(component.isLoading).toBeFalse(); + }) + ); + + it('should select a row and load its detail when clicked', fakeAsync(() => { + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackDetailAsync' + ).and.returnValue(Promise.resolve(mockLessonFeedbackDetailResponse)); + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackUnreadCountAsync' + ).and.returnValue(Promise.resolve(0)); + + component.onLearnerFeedbackRowClick('row_feedback_1'); + flushMicrotasks(); + + expect(component.selectedFeedbackId).toBe('row_feedback_1'); + expect(component.selectedFeedback).toEqual( + mockLessonFeedbackDetailResponse + ); + })); + + it('should return display details for each feedback status', () => { + expect(component.getStatusDetails(FeedbackStatus.FIXED)).toEqual({ + label: 'Lesson Updated!', + className: 'oppia-my-suggestions-status-fixed', + tooltip: + 'A creator fixed this error! Thank you for helping make Oppia' + + ' better for everyone.', + }); + expect(component.getStatusDetails(FeedbackStatus.NOT_ACTIONABLE)).toEqual({ + label: 'Reviewed by Team', + className: 'oppia-my-suggestions-status-reviewed', + tooltip: null, + }); + expect(component.getStatusDetails(FeedbackStatus.COMPLIMENT)).toEqual({ + label: 'Reviewed by Team', + className: 'oppia-my-suggestions-status-reviewed', + tooltip: null, + }); + expect(component.getStatusDetails(FeedbackStatus.SUBMITTED)).toEqual({ + label: 'Submitted', + className: 'oppia-my-suggestions-status-submitted', + tooltip: null, + }); + }); + + it('should format timestamps and lesson step descriptions', () => { + const dateTimeFormatService = TestBed.inject(DateTimeFormatService); + spyOn( + dateTimeFormatService, + 'getLocaleAbbreviatedDatetimeString' + ).and.returnValue('Jan 1, 2021'); + + expect(component.formatDate(1609459200000)).toBe('Jan 1, 2021'); + expect(component.getLessonStepDescription('End')).toBe( + 'around the "End" part of the lesson' + ); + }); + + it('should apply search locally when the server filters are unchanged', () => { + const state = component.learnerLessonFeedbackListState; + state.summaries = [ + { + ...mockLessonFeedbackSummary, + id: 'summary_a', + feedback_text_preview: 'The audio is broken', + }, + { + ...mockLessonFeedbackSummary, + id: 'summary_b', + feedback_text_preview: 'Great lesson', + }, + ]; + state.nextCursor = 'cursor_2'; + component.currentLearnerFeedbackFilterState = { + searchText: '', + status: FeedbackStatus.ALL, + technicalTeam: TechnicalTeamType.TECH_EXTERNAL, + creatorFeedbackType: CreatorFeedbackType.FEEDBACK, + dateRange: {start: null, end: null}, + }; + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ); + + component.onLearnerFeedbackFilterChange({ + searchText: 'GREAT', + status: FeedbackStatus.ALL, + technicalTeam: TechnicalTeamType.TECH_EXTERNAL, + creatorFeedbackType: CreatorFeedbackType.FEEDBACK, + dateRange: {start: null, end: null}, + }); + + expect(listSpy).not.toHaveBeenCalled(); + expect(state.displayedSummaries.map(summary => summary.id)).toEqual([ + 'summary_b', + ]); + }); + + it('should refetch from the first page when the server filters change', fakeAsync(() => { + const state = component.learnerLessonFeedbackListState; + state.summaries = [mockLessonFeedbackSummary]; + state.currentPage = 3; + state.cursorHistory = [null, 'cursor_2', 'cursor_3']; + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ).and.returnValue( + Promise.resolve({summaries: [], next_cursor: null, more: false}) + ); + + component.onLearnerFeedbackFilterChange({ + searchText: '', + status: FeedbackStatus.FIXED, + technicalTeam: TechnicalTeamType.TECH_EXTERNAL, + creatorFeedbackType: CreatorFeedbackType.FEEDBACK, + dateRange: {start: null, end: null}, + }); + flushMicrotasks(); + + expect(state.currentPage).toBe(1); + expect(state.nextCursor).toBeNull(); + expect(state.cursorHistory).toEqual([null]); + expect(listSpy).toHaveBeenCalledWith( + component.currentLearnerFeedbackFilterState, + null + ); + })); + + it( + 'should refetch from the first page when the filters are unchanged but no' + + ' data is loaded yet', + fakeAsync(() => { + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ).and.returnValue( + Promise.resolve({summaries: [], next_cursor: null, more: false}) + ); + + component.onLearnerFeedbackFilterChange({ + searchText: 'anything', + status: FeedbackStatus.ALL, + technicalTeam: TechnicalTeamType.TECH_EXTERNAL, + creatorFeedbackType: CreatorFeedbackType.FEEDBACK, + dateRange: {start: null, end: null}, + }); + flushMicrotasks(); + + expect(listSpy).toHaveBeenCalledWith( + component.currentLearnerFeedbackFilterState, + null + ); + }) + ); + + it('should not request another page when none is available', () => { + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ); + + component.learnerLessonFeedbackListState.moreAvailable = false; + component.onLearnerFeedbackNextPage(); + expect(listSpy).not.toHaveBeenCalled(); + + component.learnerLessonFeedbackListState.moreAvailable = true; + component.learnerLessonFeedbackListState.nextCursor = null; + component.onLearnerFeedbackNextPage(); + expect(listSpy).not.toHaveBeenCalled(); + }); + + it('should advance to the next page and track the cursor history', fakeAsync(() => { + const state = component.learnerLessonFeedbackListState; + state.moreAvailable = true; + state.nextCursor = 'cursor_2'; + state.currentPage = 1; + state.cursorHistory = [null]; + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ).and.returnValue( + Promise.resolve({summaries: [], next_cursor: null, more: false}) + ); + + component.onLearnerFeedbackNextPage(); + flushMicrotasks(); + + expect(listSpy).toHaveBeenCalledWith( + component.currentLearnerFeedbackFilterState, + 'cursor_2' + ); + expect(state.cursorHistory).toEqual([null, 'cursor_2']); + expect(state.currentPage).toBe(2); + })); + + it( + 'should reuse an existing cursor history entry when moving forward after' + + ' going back', + fakeAsync(() => { + const state = component.learnerLessonFeedbackListState; + state.moreAvailable = true; + state.nextCursor = 'cursor_3'; + state.currentPage = 2; + state.cursorHistory = [null, 'cursor_2', 'cursor_3']; + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ).and.returnValue( + Promise.resolve({summaries: [], next_cursor: 'cursor_3', more: true}) + ); + + component.onLearnerFeedbackNextPage(); + flushMicrotasks(); + + // The history entry for page 3 already exists, so it is not duplicated. + expect(state.cursorHistory).toEqual([null, 'cursor_2', 'cursor_3']); + expect(state.currentPage).toBe(3); + expect(listSpy).toHaveBeenCalledWith( + component.currentLearnerFeedbackFilterState, + 'cursor_3' + ); + }) + ); + + it('should not go back past the first page', () => { + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ); + + component.learnerLessonFeedbackListState.currentPage = 1; + component.onLearnerFeedbackPreviousPage(); + + expect(listSpy).not.toHaveBeenCalled(); + }); + + it('should return to the previous page using the stored cursor', fakeAsync(() => { + const state = component.learnerLessonFeedbackListState; + state.currentPage = 2; + state.cursorHistory = [null, 'cursor_2']; + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ).and.returnValue( + Promise.resolve({summaries: [], next_cursor: 'cursor_2', more: true}) + ); + + component.onLearnerFeedbackPreviousPage(); + flushMicrotasks(); + + expect(listSpy).toHaveBeenCalledWith( + component.currentLearnerFeedbackFilterState, + null + ); + expect(state.currentPage).toBe(1); + })); + + it('should warn when loading a feedback detail fails', fakeAsync(() => { + const alertsService = TestBed.inject(AlertsService); + const addWarningSpy = spyOn(alertsService, 'addWarning'); + const loaderService = TestBed.inject(LoaderService); + const hideLoadingSpy = spyOn(loaderService, 'hideLoadingScreen'); + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackDetailAsync' + ).and.returnValue(Promise.reject()); + + component.openFeedbackDetail('feedback_bad'); + flushMicrotasks(); + + expect(addWarningSpy).toHaveBeenCalledWith( + 'Failed to load this suggestion.' + ); + expect(hideLoadingSpy).toHaveBeenCalled(); + })); + + it('should ignore a stale detail response after selecting other feedback', fakeAsync(() => { + let resolveDetail!: (response: LessonFeedbackDetailResponse) => void; + spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackDetailAsync' + ).and.returnValue( + new Promise(resolve => { + resolveDetail = resolve; + }) + ); + const unreadCountSpy = spyOn( + feedbackBackendApiService, + 'fetchMyFeedbackUnreadCountAsync' + ).and.returnValue(Promise.resolve(0)); + + component.openFeedbackDetail('feedback_a'); + component.selectedFeedbackId = 'feedback_b'; + resolveDetail(mockLessonFeedbackDetailResponse); + flushMicrotasks(); + + expect(component.selectedFeedback).toBeNull(); + expect(unreadCountSpy).not.toHaveBeenCalled(); + })); + + it('should allow follow-up notes only for fixed feedback', () => { + component.selectedFeedback = { + ...mockLessonFeedbackDetailResponse, + status: FeedbackStatus.FIXED, + }; + expect(component.canAddFollowUpNote()).toBeTrue(); + + component.selectedFeedback = mockLessonFeedbackDetailResponse; + expect(component.canAddFollowUpNote()).toBeFalse(); + + component.selectedFeedback = null; + expect(component.canAddFollowUpNote()).toBeFalse(); + }); + + it( + 'should clear the selection and refresh the list when navigating back to' + + ' the list view', + fakeAsync(() => { + component.selectedFeedbackId = 'feedback_id'; + component.selectedFeedback = mockLessonFeedbackDetailResponse; + const replaceStateSpy = spyOn(window.history, 'replaceState'); + const loaderShowSpy = spyOn( + TestBed.inject(LoaderService), + 'showLoadingScreen' + ); + const listSpy = spyOn( + feedbackBackendApiService, + 'fetchLearnerLessonFeedbackListAsync' + ).and.returnValue( + Promise.resolve({summaries: [], next_cursor: null, more: false}) + ); + + component.goBackToListView(); + flushMicrotasks(); + + expect(component.selectedFeedbackId).toBeNull(); + expect(component.selectedFeedback).toBeNull(); + expect(replaceStateSpy).toHaveBeenCalledWith( + {}, + '', + '/learner-dashboard' + ); + expect(listSpy).toHaveBeenCalled(); + expect(loaderShowSpy).toHaveBeenCalledWith('Loading'); + }) + ); +}); diff --git a/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.ts b/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.ts new file mode 100644 index 0000000000000..056b7accc428e --- /dev/null +++ b/core/templates/pages/learner-dashboard-page/my-suggestions-tab.component.ts @@ -0,0 +1,374 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Component for the My Suggestions tab in learner dashboard. + */ + +import {Component, EventEmitter, OnInit, Output} from '@angular/core'; +import {MatBottomSheet} from '@angular/material/bottom-sheet'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; + +import {FeedbackBackendApiService} from 'domain/feedback/feedback-backend-api.service'; +import { + CreatorFeedbackType, + FeedbackCardConfig, + FeedbackFilterState, + FeedbackStatus, + LessonFeedbackBackendResponse, + LessonFeedbackDetailResponse, + LessonFeedbackSummary, + TechnicalTeamType, +} from 'domain/feedback/feedback.model'; +import {DateTimeFormatService} from 'services/date-time-format.service'; +import {UrlService} from 'services/contextual/url.service'; +import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; +import {AlertsService} from 'services/alerts.service'; +import {LoaderService} from 'services/loader.service'; +import {WindowRef} from 'services/contextual/window-ref.service'; +import {MY_SUGGESTIONS_FILTER_CONFIG} from '../../domain/feedback/feedback.model'; +import {AddAFollowUpNoteModalComponent} from './add-a-follow-up-note-modal/add-a-follow-up-note-modal.component'; + +import './my-suggestions-tab.component.css'; + +interface LearnerFeedbackStatusDetails { + label: string; + className: string; + tooltip: string | null; +} + +interface LearnerFeedbackListState { + summaries: TSummary[]; + displayedSummaries: TSummary[]; + currentPage: number; + nextCursor: string | null; + cursorHistory: (string | null)[]; + moreAvailable: boolean; +} + +@Component({ + selector: 'oppia-my-suggestions-tab', + templateUrl: './my-suggestions-tab.component.html', + styleUrls: ['./my-suggestions-tab.component.css'], +}) +export class MySuggestionsTabComponent implements OnInit { + @Output() unreadCountChanged = new EventEmitter(); + readonly learnerFeedbackFilterConfig = MY_SUGGESTIONS_FILTER_CONFIG; + readonly learnerLessonFeedbackCardConfig: FeedbackCardConfig = { + showCategory: false, + showLesson: true, + showResponse: false, + showScreenshot: false, + showLessonMetadata: false, + showSessionInfo: false, + showNotificationSummary: true, + }; + + currentLearnerFeedbackFilterState: FeedbackFilterState = { + searchText: '', + status: FeedbackStatus.ALL, + technicalTeam: TechnicalTeamType.TECH_EXTERNAL, + creatorFeedbackType: CreatorFeedbackType.FEEDBACK, + dateRange: { + start: null, + end: null, + }, + }; + selectedFeedbackId: string | null = null; + selectedFeedback: LessonFeedbackDetailResponse | null = null; + isLoading = true; + learnerLessonFeedbackListState: LearnerFeedbackListState = + { + summaries: [], + displayedSummaries: [], + currentPage: 1, + nextCursor: null, + cursorHistory: [null], + moreAvailable: false, + }; + + constructor( + private loaderService: LoaderService, + private dateTimeFormatService: DateTimeFormatService, + private urlService: UrlService, + private feedbackBackendApiService: FeedbackBackendApiService, + private ngbModal: NgbModal, + private alertService: AlertsService, + private windowRef: WindowRef, + private bottomSheet: MatBottomSheet, + private windowDimensionsService: WindowDimensionsService + ) {} + + async ngOnInit(): Promise { + const urlParams = this.urlService.getUrlParams(); + const feedbackId = urlParams.feedback_id; + try { + if (feedbackId) { + await this.openFeedbackDetail(feedbackId); + } else { + await this.fetchLearnerFeedbackPage(); + } + } finally { + this.isLoading = false; + } + } + getDisplayedLearnerFeedbackSummaries(): LessonFeedbackSummary[] { + return this.learnerLessonFeedbackListState.displayedSummaries; + } + + getLearnerFeedbackListCurrentPage(): number { + return this.learnerLessonFeedbackListState.currentPage; + } + + getLearnerFeedbackListMoreAvailable(): boolean { + return this.learnerLessonFeedbackListState.moreAvailable; + } + + onLearnerFeedbackRowClick(feedbackId: string): void { + this.selectedFeedbackId = feedbackId; + this.openFeedbackDetail(feedbackId); + } + + formatDate(timestampMsecs: number): string { + return this.dateTimeFormatService.getLocaleAbbreviatedDatetimeString( + timestampMsecs + ); + } + + getStatusDetails(status: FeedbackStatus): LearnerFeedbackStatusDetails { + if (status === FeedbackStatus.FIXED) { + return { + label: 'Lesson Updated!', + className: 'oppia-my-suggestions-status-fixed', + tooltip: + 'A creator fixed this error! Thank you for helping make Oppia better for everyone.', + }; + } + if ( + status === FeedbackStatus.NOT_ACTIONABLE || + status === FeedbackStatus.COMPLIMENT + ) { + return { + label: 'Reviewed by Team', + className: 'oppia-my-suggestions-status-reviewed', + tooltip: null, + }; + } + return { + label: 'Submitted', + className: 'oppia-my-suggestions-status-submitted', + tooltip: null, + }; + } + + getLessonStepDescription(stateName: string): string { + return `around the "${stateName}" part of the lesson`; + } + + private fetchLearnerFeedbackPage( + cursor: string | null = null + ): Promise { + this.loaderService.showLoadingScreen('Loading'); + return this.feedbackBackendApiService + .fetchLearnerLessonFeedbackListAsync( + this.currentLearnerFeedbackFilterState, + cursor + ) + .then(response => { + this.updateLearnerLessonFeedbackPage(response); + }) + .finally(() => this.loaderService.hideLoadingScreen()); + } + + private updateLearnerLessonFeedbackPage( + response: LessonFeedbackBackendResponse + ): void { + this.learnerLessonFeedbackListState.summaries = response.summaries; + + this.learnerLessonFeedbackListState.summaries.forEach(summary => { + if ( + summary.status === FeedbackStatus.OPEN || + summary.status === FeedbackStatus.FIXED + ) { + summary.status = + summary.status === FeedbackStatus.OPEN + ? FeedbackStatus.SUBMITTED + : FeedbackStatus.LESSON_UPDATED; + } else { + summary.status = FeedbackStatus.REVIEWED_BY_TEAM; + } + }); + + this.learnerLessonFeedbackListState.nextCursor = response.next_cursor; + this.learnerLessonFeedbackListState.moreAvailable = response.more; + + this.applyLearnerFeedbackSearch(this.learnerLessonFeedbackListState); + } + + private applyLearnerFeedbackSearch( + state: LearnerFeedbackListState + ): void { + const searchText = this.currentLearnerFeedbackFilterState.searchText + ?.toLocaleLowerCase() + .trim(); + + if (!searchText) { + state.displayedSummaries = [...state.summaries]; + return; + } + + state.displayedSummaries = state.summaries.filter(summary => + summary.feedback_text_preview.toLocaleLowerCase().includes(searchText) + ); + } + + onLearnerFeedbackNextPage(): void { + const state = this.learnerLessonFeedbackListState; + if (!state.moreAvailable || !state.nextCursor) { + return; + } + const nextPage = state.currentPage; + if (state.cursorHistory.length === nextPage) { + state.cursorHistory.push(state.nextCursor); + } + + this.fetchLearnerFeedbackPage(state.nextCursor).then(() => { + state.currentPage++; + }); + } + + onLearnerFeedbackPreviousPage(): void { + const state = this.learnerLessonFeedbackListState; + if (state.currentPage <= 1) { + return; + } + + const previousCursor = state.cursorHistory[state.currentPage - 2]; + this.fetchLearnerFeedbackPage(previousCursor).then(() => { + state.currentPage--; + }); + } + + private async markFeedbackSummaryAsRead(feedbackId: string): Promise { + const markSummaryRead = ( + summary: LessonFeedbackSummary + ): LessonFeedbackSummary => + summary.id === feedbackId + ? {...summary, unread_response_count: 0} + : summary; + const state = this.learnerLessonFeedbackListState; + state.summaries = state.summaries.map(markSummaryRead); + state.displayedSummaries = state.displayedSummaries.map(markSummaryRead); + + // Re-fetch the authoritative global unread total so the shared count + // stays correct even when unread feedback exists beyond the pages that + // are currently loaded. + try { + const unreadCount = + await this.feedbackBackendApiService.fetchMyFeedbackUnreadCountAsync(); + this.unreadCountChanged.emit(unreadCount); + } catch { + // Leave the shared unread count unchanged when the refresh fails. + } + } + + async openFeedbackDetail(feedbackId: string): Promise { + this.selectedFeedbackId = feedbackId; + this.selectedFeedback = null; + this.loaderService.showLoadingScreen('Loading'); + + try { + const response = + await this.feedbackBackendApiService.fetchMyFeedbackDetailAsync( + feedbackId + ); + if (this.selectedFeedbackId !== feedbackId) { + return; + } + this.selectedFeedback = response; + await this.markFeedbackSummaryAsRead(feedbackId); + } catch (error) { + this.alertService.addWarning('Failed to load this suggestion.'); + } finally { + this.loaderService.hideLoadingScreen(); + } + } + + canAddFollowUpNote(): boolean { + return this.selectedFeedback?.status === FeedbackStatus.FIXED; + } + + openFollowUpModal(): void { + if (this.windowDimensionsService.isWindowNarrow()) { + this.bottomSheet.open(AddAFollowUpNoteModalComponent, { + data: {detailFeedback: this.selectedFeedback}, + }); + return; + } + + const modalRef = this.ngbModal.open(AddAFollowUpNoteModalComponent, { + backdrop: 'static', + }); + + modalRef.componentInstance.detailFeedback = this.selectedFeedback; + } + + private hasSameCreatorFeedbackServerFilters( + filterState: FeedbackFilterState + ): boolean { + return ( + this.currentLearnerFeedbackFilterState.status === filterState.status && + this.currentLearnerFeedbackFilterState.dateRange.start?.getTime() === + filterState.dateRange.start?.getTime() && + this.currentLearnerFeedbackFilterState.dateRange.end?.getTime() === + filterState.dateRange.end?.getTime() + ); + } + + onLearnerFeedbackFilterChange(filterState: FeedbackFilterState): void { + const hasSameServerFilters = + this.hasSameCreatorFeedbackServerFilters(filterState); + + this.currentLearnerFeedbackFilterState = filterState; + + const state = this.learnerLessonFeedbackListState; + + if ( + hasSameServerFilters && + (state.summaries.length > 0 || + state.nextCursor !== null || + state.currentPage > 1) + ) { + this.applyLearnerFeedbackSearch(this.learnerLessonFeedbackListState); + return; + } + + state.currentPage = 1; + state.nextCursor = null; + state.cursorHistory = [null]; + + this.fetchLearnerFeedbackPage(); + } + + goBackToListView(): void { + this.selectedFeedbackId = null; + this.selectedFeedback = null; + this.windowRef.nativeWindow.history.replaceState( + {}, + '', + '/learner-dashboard' + ); + this.fetchLearnerFeedbackPage(); + } +} diff --git a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.spec.ts b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.spec.ts index 68c471a3dffbd..9752993d3d07e 100644 --- a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.spec.ts +++ b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for for OldProgressTabComponent. */ +// @ts-nocheck + import { waitForAsync, ComponentFixture, diff --git a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.spec.ts b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.spec.ts index 7cda02df8da05..ada65712c76c6 100644 --- a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.spec.ts +++ b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for SkillCardComponent */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {FormsModule} from '@angular/forms'; import { diff --git a/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.css b/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.css new file mode 100644 index 0000000000000..dc65574dd7989 --- /dev/null +++ b/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.css @@ -0,0 +1,13 @@ +.btn-primary { + background-color: #00645c; + border: solid 1px #00645c; + margin-right: 20px; + padding: 6px 16px; +} + +.btn-danger { + background: #fff; + border: solid 1px #a53417; + color: #a53417; + padding: 6px 16px; +} diff --git a/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.html b/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.html index 716bdb71c803d..f3a6d8053ad74 100644 --- a/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.html +++ b/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.html @@ -8,19 +8,3 @@

    - - diff --git a/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.ts b/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.ts index d68828c54b62e..f6a56bb7b6ad4 100644 --- a/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.ts +++ b/core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.ts @@ -19,10 +19,12 @@ import {Component} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './exit-learner-group-modal.component.css'; @Component({ selector: 'oppia-exit-learner-group-modal', templateUrl: './exit-learner-group-modal.component.html', + styleUrls: ['./exit-learner-group-modal.component.css'], }) export class ExitLearnerGroupModalComponent extends ConfirmOrCancelModal { learnerGroupTitle!: string; diff --git a/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.css b/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.css new file mode 100644 index 0000000000000..bb93cc7b4331e --- /dev/null +++ b/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.css @@ -0,0 +1,15 @@ +.btn-primary { + background-color: #00645c; + border: solid 1px #00645c; + margin-right: 20px; + padding: 6px 16px; +} + +.btn-primary:disabled { + background: #9e9e9e; + border: solid 1px #9e9e9e; +} + +.btn-primary.send-invitation-button { + margin-right: unset; +} diff --git a/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.html b/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.html index d1e896dfa2403..16cdeb5b485d6 100644 --- a/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.html +++ b/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.html @@ -8,21 +8,3 @@ - - diff --git a/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.ts b/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.ts index a6d7f054d7313..d448d2772c704 100644 --- a/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.ts +++ b/core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.ts @@ -20,10 +20,12 @@ import {Component} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; import {LearnerGroupUserInfo} from 'domain/learner_group/learner-group-user-info.model'; +import './invite-learners-modal.component.css'; @Component({ selector: 'oppia-invite-learners-modal', templateUrl: './invite-learners-modal.component.html', + styleUrls: ['./invite-learners-modal.component.css'], }) export class InviteLearnersModalComponent extends ConfirmOrCancelModal { learnerGroupId!: string; diff --git a/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.css b/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.css new file mode 100644 index 0000000000000..d1140c12f4cce --- /dev/null +++ b/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.css @@ -0,0 +1,6 @@ +.modal-body p { + margin: unset; +} +.primary-color { + color: #00645c; +} diff --git a/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.html b/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.html index 55c4e57253922..66092ae7b8b0e 100644 --- a/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.html +++ b/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.html @@ -11,12 +11,3 @@
    - - diff --git a/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.ts b/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.ts index 1a22b4aaa738b..f9a9283d582cc 100644 --- a/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.ts +++ b/core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.ts @@ -19,10 +19,12 @@ import {Component} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './invite-successful-modal.component.css'; @Component({ selector: 'oppia-invite-successful-modal', templateUrl: './invite-successful-modal.component.html', + styleUrls: ['./invite-successful-modal.component.css'], }) export class InviteSuccessfulModalComponent extends ConfirmOrCancelModal { successMessage!: string; diff --git a/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.css b/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.css new file mode 100644 index 0000000000000..4afb61728a92d --- /dev/null +++ b/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.css @@ -0,0 +1,45 @@ +.modal-header-title { + border-bottom: solid 1px #e0e0e0; +} + +.modal-header-title p { + margin: 0; +} + +.primary-color { + color: #00645c; +} + +.btn-primary { + background-color: #00645c; + border: solid 1px #00645c; + margin-right: 20px; + padding: 6px 16px; +} + +.btn-secondary { + border: solid 1px #00645c; + color: #00645c; + margin-right: 20px; + padding: 6px 16px; +} + +.btn-danger { + background: #fff; + border: solid 1px #a53417; + color: #a53417; + padding: 6px 16px; +} + +.modal-header .btn-close { + cursor: pointer; + margin: 0.75rem 0 0.75rem auto; + opacity: 0.5; +} + +.group-title, +.group-description, +.learner-groups-progress-sharing-info, +.group-facilitator { + margin-bottom: 15px; +} diff --git a/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.html b/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.html index ea2992ee3b868..30f7b8635c516 100644 --- a/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.html +++ b/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.html @@ -38,51 +38,3 @@ - - diff --git a/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.ts b/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.ts index 696d4a24c6ce1..a71a16ef59fec 100644 --- a/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.ts +++ b/core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.ts @@ -20,10 +20,12 @@ import {Component} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; import {LearnerGroupData} from 'domain/learner_group/learner-group.model'; +import './learner-group-preferences-modal.component.css'; @Component({ selector: 'oppia-learner-group-preferences-modal', templateUrl: './learner-group-preferences-modal.component.html', + styleUrls: ['./learner-group-preferences-modal.component.css'], }) export class LearnerGroupPreferencesModalComponent extends ConfirmOrCancelModal { learnerGroup!: LearnerGroupData; diff --git a/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.css b/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.css new file mode 100644 index 0000000000000..dc65574dd7989 --- /dev/null +++ b/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.css @@ -0,0 +1,13 @@ +.btn-primary { + background-color: #00645c; + border: solid 1px #00645c; + margin-right: 20px; + padding: 6px 16px; +} + +.btn-danger { + background: #fff; + border: solid 1px #a53417; + color: #a53417; + padding: 6px 16px; +} diff --git a/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.html b/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.html index 8eedf13140a8c..ba1b7921eac88 100644 --- a/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.html +++ b/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.html @@ -12,19 +12,3 @@

    - - diff --git a/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.ts b/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.ts index 977a80fbcc72e..cb823da55cf14 100644 --- a/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.ts +++ b/core/templates/pages/learner-group-pages/templates/remove-item-modal.component.ts @@ -19,10 +19,12 @@ import {Component} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './remove-item-modal.component.css'; @Component({ selector: 'oppia-remove-item-modal', templateUrl: './remove-item-modal.component.html', + styleUrls: ['./remove-item-modal.component.css'], }) export class RemoveItemModalComponent extends ConfirmOrCancelModal { confirmationTitle!: string; diff --git a/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.css b/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.css new file mode 100644 index 0000000000000..5a30193f815ca --- /dev/null +++ b/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.css @@ -0,0 +1,29 @@ +.add-syllabus-success-message { + font-weight: 500; + text-align: center; +} + +.btn-primary { + background-color: #00645c; + border: solid 1px #00645c; + font-size: 18px; + font-weight: 500; + margin-bottom: 24px; + margin-right: 0; + padding: 6px 16px; +} + +.continue-to-edit { + color: #00645c; + cursor: pointer; + font-weight: 500; + text-decoration: underline; +} + +.modal-footer { + border-top: unset; + display: block; + padding-bottom: 40px; + padding-top: 20px; + text-align: center; +} diff --git a/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.html b/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.html index 90b0b9cc7bc5e..09fda7be4ab4e 100644 --- a/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.html +++ b/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.html @@ -23,35 +23,3 @@
    Continue to edit
    - - diff --git a/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.ts b/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.ts index e263756fa6152..659aeae79ce98 100644 --- a/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.ts +++ b/core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.ts @@ -19,10 +19,12 @@ import {Component} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './syllabus-addition-success-modal.component.css'; @Component({ selector: 'oppia-syllabus-addition-success-modal', templateUrl: './syllabus-addition-success-modal.component.html', + styleUrls: ['./syllabus-addition-success-modal.component.css'], }) export class SyllabusAdditionSuccessModalComponent extends ConfirmOrCancelModal { itemsAddedCount!: number; diff --git a/core/templates/pages/learner-group-pages/view-group/learner-group-view-assigned-syllabus.component.ts b/core/templates/pages/learner-group-pages/view-group/learner-group-view-assigned-syllabus.component.ts index c386d0994729b..5f3da60f472e5 100644 --- a/core/templates/pages/learner-group-pages/view-group/learner-group-view-assigned-syllabus.component.ts +++ b/core/templates/pages/learner-group-pages/view-group/learner-group-view-assigned-syllabus.component.ts @@ -34,6 +34,7 @@ import './learner-group-view-assigned-syllabus.component.css'; @Component({ selector: 'oppia-learner-group-view-assigned-syllabus', templateUrl: './learner-group-view-assigned-syllabus.component.html', + styleUrls: ['./learner-group-view-assigned-syllabus.component.css'], }) export class LearnerGroupViewAssignedSyllabusComponent implements OnInit { @Input() learnerGroup!: LearnerGroupData; diff --git a/core/templates/pages/learner-group-pages/view-group/view-learner-group-page.component.ts b/core/templates/pages/learner-group-pages/view-group/view-learner-group-page.component.ts index e1d46c21460a8..5f534bb32782b 100644 --- a/core/templates/pages/learner-group-pages/view-group/view-learner-group-page.component.ts +++ b/core/templates/pages/learner-group-pages/view-group/view-learner-group-page.component.ts @@ -36,6 +36,7 @@ import './view-learner-group-page.component.css'; @Component({ selector: 'oppia-view-learner-group-page', templateUrl: './view-learner-group-page.component.html', + styleUrls: ['./view-learner-group-page.component.css'], }) export class ViewLearnerGroupPageComponent implements OnInit { VIEW_LEARNER_GROUP_TABS_I18N_IDS = diff --git a/core/templates/pages/library-page/classroom-card/classroom-card.component.css b/core/templates/pages/library-page/classroom-card/classroom-card.component.css new file mode 100644 index 0000000000000..10a292b65aace --- /dev/null +++ b/core/templates/pages/library-page/classroom-card/classroom-card.component.css @@ -0,0 +1,140 @@ +.classroom-content .classroom-text-container { + align-items: center; + display: flex; + flex-direction: column; + gap: 24px; + position: relative; + text-align: center; + width: 100%; +} + +.classroom-content .classroom-thumbnail-big { + height: 144px; + width: 186px; +} + +.classroom-content .classroom-thumbnail-small { + height: 88px; + width: 88px; +} + +.classroom-name { + -webkit-box-orient: vertical; + color: #005240; + display: -webkit-box; + font-family: 'Capriola', 'Roboto', Arial, sans-serif; + font-size: 20px; + font-style: normal; + font-weight: 400; + hyphens: auto; + letter-spacing: 0.0075em; + -webkit-line-clamp: 2; + line-clamp: 2; + margin: 0; + overflow: hidden; + overflow-wrap: anywhere; + text-align: center; +} + +.classroom-card-dropshadow { + box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); +} + +.oppia-classroom-card-container { + align-items: center; + background-color: #fff; + border-radius: 4px; + display: flex; + justify-content: center; + padding: 35px; +} + +.classroom-card-big { + height: 260px; + width: 240px; +} + +.classroom-card-small { + height: 184px; + width: 164px; +} + +.oppia-classroom-card-container .classroom-content { + align-items: center; + bottom: 14px; + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + justify-content: start; + position: relative; + text-decoration: none; + width: 100%; +} + +.classroom-content-small { + gap: 24px; +} + +@media only screen and (max-width: 1024px) { + .classroom-card-big { + height: 216px; + width: 192px; + } +} + +@media only screen and (max-width: 900px) { + .oppia-classroom-card-container { + height: 216px; + width: 196px; + } + + .oppia-classroom-card-container .classroom-content { + gap: 10px; + } + + .classroom-card-small { + height: 142px; + width: 140px; + } + + .classroom-content .classroom-thumbnail-small { + bottom: 10px; + position: relative; + } + + .classroom-name { + -webkit-line-clamp: 1; + line-clamp: 1; + } +} + +@media only screen and (max-width: 575px) { + .classroom-text-container { + max-width: 134px; + } + + .oppia-classroom-card-container .classroom-content { + justify-content: space-around; + } + .classroom-card-big { + height: 142px; + width: 140px; + } + + .classroom-card-small { + height: 100px; + width: 100px; + } + + .classroom-content .classroom-thumbnail-small { + bottom: 0; + height: 52px; + scale: 1.4; + width: 52px; + } + + .classroom-name { + font-size: 12px; + } +} diff --git a/core/templates/pages/library-page/classroom-card/classroom-card.component.html b/core/templates/pages/library-page/classroom-card/classroom-card.component.html index b1336151d7f5c..65292cf3f5de7 100644 --- a/core/templates/pages/library-page/classroom-card/classroom-card.component.html +++ b/core/templates/pages/library-page/classroom-card/classroom-card.component.html @@ -10,146 +10,3 @@ - - diff --git a/core/templates/pages/library-page/classroom-card/classroom-card.component.spec.ts b/core/templates/pages/library-page/classroom-card/classroom-card.component.spec.ts index d36454d5f51fe..9171678f6c4aa 100644 --- a/core/templates/pages/library-page/classroom-card/classroom-card.component.spec.ts +++ b/core/templates/pages/library-page/classroom-card/classroom-card.component.spec.ts @@ -15,6 +15,7 @@ /** * @fileoverview Unit tests for ClassroomCardComponent. */ +// @ts-nocheck import {HttpClientTestingModule} from '@angular/common/http/testing'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; diff --git a/core/templates/pages/library-page/classroom-card/classroom-card.component.ts b/core/templates/pages/library-page/classroom-card/classroom-card.component.ts index 3f6e372df09e4..a009e433b04d1 100644 --- a/core/templates/pages/library-page/classroom-card/classroom-card.component.ts +++ b/core/templates/pages/library-page/classroom-card/classroom-card.component.ts @@ -21,10 +21,12 @@ import {AppConstants} from 'app.constants'; import {ClassroomSummaryDict} from 'domain/classroom/classroom-backend-api.service'; import {AssetsBackendApiService} from 'services/assets-backend-api.service'; import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; +import './classroom-card.component.css'; @Component({ selector: 'oppia-classroom-card', templateUrl: './classroom-card.component.html', + styleUrls: ['./classroom-card.component.css'], }) export class ClassroomCardComponent { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/library-page/library-page.component.spec.ts b/core/templates/pages/library-page/library-page.component.spec.ts index f2e86a8eb659c..cabde6b6dce0f 100644 --- a/core/templates/pages/library-page/library-page.component.spec.ts +++ b/core/templates/pages/library-page/library-page.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the component of the library page. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import { NO_ERRORS_SCHEMA, diff --git a/core/templates/pages/library-page/search-bar/search-bar.component.css b/core/templates/pages/library-page/search-bar/search-bar.component.css index 250793812ea54..46de92e414358 100644 --- a/core/templates/pages/library-page/search-bar/search-bar.component.css +++ b/core/templates/pages/library-page/search-bar/search-bar.component.css @@ -1,322 +1,71 @@ -/* - NOTE to developers: We generate RTL CSS from this file in the webpack - compilation, here are sme additional rules that can be added to the CSS files: - https://rtlcss.com/learn/usage-guide/control-directives . -*/ -oppia-search-bar .dropup .dropdown-menu, -oppia-search-bar .navbar-fixed-bottom .dropdown .dropdown-menu { - bottom: auto; +:lang(ar) .oppia-search-bar-dropdown-toggle-button:after { + margin-right: 30px; } -.oppia-search-bar-input { - width: 220px; -} -.oppia-search-bar-input, -.oppia-search-button { - background: #fff; - border-color: #257d76; - border-radius: 0; - border-style: solid; - border-width: 1px 1px 1px 0; - color: #257d76; - font-size: 15px; - height: 35px; -} - - -oppia-search-bar .oppia-same-row-container { - float: left; -} - -oppia-search-bar .oppia-navbar-button-container { - border-radius: 0; - /* This is needed so that the category and language dropdowns - in the library/search page do not fall behind the exploration - summary tiles. */ - z-index: 15; -} - -.search-bar-float-start { - float: left; -} - -.oppia-search-button { - background: #257d76; - border-bottom-right-radius: 4px; - border-top-right-radius: 4px; - color: #fff; - padding: 3px 15px 3px 15px; -} - -.oppia-search-button:hover { - cursor: pointer; -} - -.oppia-search-bar-form { - margin-top: 10px; - padding-right: 0; -} -.oppia-search-bar-input.btn { - color: #257d76; -} -.oppia-search-bar-icon { - background: #fff; - border-color: #257d76; - border-style: solid; - border-width: 1px 0 1px 1px; - color: #257d76; -} -.input-group-addon.oppia-search-bar-icon { - border-radius: 4px 0 0 4px; - font-size: 14px; - font-weight: normal; - line-height: 1; - padding: 7px 12px; - text-align: center; -} -.oppia-search-bar-input.btn:hover, -.oppia-search-bar-input.btn:focus { - background: #fff; - border-color: #257d76; - border-radius: 0; - color: #257d76; - height: 35px; -} - -.oppia-search-bar-input.btn:focus { - outline: 1px dotted #fff; - outline: auto 5px -webkit-focus-ring-color; +:lang(ar) .oppia-search-bar-dropdown-toggle:after { + margin-right: 2px; } +@media (max-width: 445px) { -.oppia-search-bar-input::-webkit-input-placeholder { - color: #afafaf; -} -.oppia-search-bar-input::-moz-placeholder { - color: #afafaf; -} -.oppia-search-bar-input:-moz-placeholder { - color: #afafaf; -} -.oppia-search-bar-input:-ms-input-placeholder { - color: #afafaf; -} -oppia-search-bar .oppia-input-group { - max-width: 375px; - width: 17em; -} -.oppia-search-bar-dropdown-toggle { - border-radius: 0; - max-width: 150px; -} -.oppia-search-bar-dropdown-menu-section { - max-height: 230px; - overflow: auto; -} -.oppia-search-bar-hr { - margin: 2px; -} -.oppia-search-bar-dropdown-toggle-button { - border-radius: 0; - max-width: 150px; -} - -.btn-text { - color: #257d76; - margin-left: 8px; -} - -@media (max-width: 1125px) { - .btn-text { - color: #257d76; - } - .oppia-search-bar-input { - color: #257d76; - } - .oppia-search-bar-input::-webkit-input-placeholder { - color: #afafaf; - } - .oppia-search-bar-input::-moz-placeholder { - color: #afafaf; - } - .oppia-search-bar-input:-moz-placeholder { - color: #afafaf; - } - .oppia-search-bar-input:-ms-input-placeholder { - color: #afafaf; - } - .oppia-search-bar-icon { - color: #257d76; - } - .oppia-search-bar-input.btn { - color: #257d76; + .classroom-page-input-group.inptcls { + font-size: 15.7px; + width: 14em; } - .oppia-search-bar-input.btn:hover, - .oppia-search-bar-input.btn:focus { - color: #257d76; - } - .oppia-search-bar-input:focus { - color: #257d76; - } -} -@media (max-width: 767px) { - .btn-text { - color: #fff; - } - .oppia-search-bar-input { - color: #257d76; - padding-top: 17px; - } - .oppia-search-button, - .oppia-search-bar-input { - border-radius: 0; - height: 45px; - } - .oppia-search-button { - border-bottom-right-radius: 10px; - border-top-right-radius: 10px; - padding: 8px 11px 9px 9px; - } - .oppia-input-group.classroom-page-input-group { - width: 17em; - } - .oppia-search-bar-form .input-group .oppia-search-bar-text-input { - border: 3px solid #257d76; - border-bottom-left-radius: 10px; - border-bottom-right-radius: 0; - border-top-left-radius: 10px; - border-top-right-radius: 0; - height: 45px; - padding-top: 8px; - } - oppia-search-bar .oppia-input-group input { - margin-left: -4.5vw; - } - .oppia-search-bar-input::-webkit-input-placeholder { - color: #999; - } - .oppia-search-bar-input::-moz-placeholder { - color: #999; - } - .oppia-search-bar-input:-moz-placeholder { - color: #999; - } - .oppia-search-bar-input:-ms-input-placeholder { - color: #999; - } - .oppia-search-bar-input.btn { - background-color: #257d76; - border-left: 1px solid #257d76; - border-radius: 10px; - color: #fff; - height: 40px; - margin-left: 3px; - margin-top: -60px; - max-width: none; - padding: 8px 7px 0 6px; - text-align: center; - width: 155px; - } - .oppia-search-bar-input.btn:hover, - .oppia-search-bar-input.btn:focus { - color: #fff; - } - .e2e-test-search-bar-dropdown-menu { - margin-top: 1px; - } - oppia-search-bar .oppia-same-row-container { - display: inline-block; - } - .oppia-search-bar-category-selector { - margin-top: 95px; - right: 9px; - transform: translate(-284px, 40px); - width: 0; - z-index: 2; - } - .oppia-search-bar-language-selector { - margin-top: 95px; - transform: translate(-120px, 40px); - width: 0; - z-index: 2; - } - oppia-search-bar .oppia-dropdown-label { - color: #257d76; - display: block; - font-size: 10px; - font-weight: 300; - left: 23px; - opacity: 0.95; - position: absolute; - top: -35px; - } - .oppia-search-bar-dropdown-toggle-button { - display: flex; - justify-content: space-between; - } - .oppia-search-bar-dropdown-toggle-button::after { - font-size: 24px; - margin-top: 7px; - } - .oppia-search-bar-dropdown-toggle { - display: flex; - justify-content: space-between; - } - .oppia-search-bar-dropdown-toggle::after { - font-size: 24px; - margin-top: 7px; - } - oppia-search-bar .category-selector-icon, - oppia-search-bar .language-selector-icon { - margin-top: 2px; + /* stylelint-disable order/properties-alphabetical-order */ + .search-bar-float-start .oppia-search-bar-dropdown-toggle.catbtn { + font-size: 13px; + position: relative; + left: 5rem; + width: 11em; } - oppia-search-bar .dropdown-menu { - margin-top: 1px; - width: 130px; + .oppia-search-bar-language-selector .langbtn { + font-size: 13px; + position: relative; + left: 3rem; + width: 11em; } - oppia-search-bar .oppia-translate-icon-down { - transform: translate(0,6px); + /* stylelint-enable order/properties-alphabetical-order */ + :lang(ar) .classroom-page-input-group.inptcls { + font-size: 12px; + width: 14em; } - .oppia-search-bar-icon { - display: none; + :lang(ar) .search-bar-float-start .oppia-search-bar-dropdown-toggle.catbtn { + font-size: 13px; + position: relative; + right: 9rem; + width: 10em; } - .search-button-icon { - font-size: 24px; - margin: auto; + :lang(ar) .oppia-search-bar-language-selector .langbtn { + font-size: 13px; + padding-left: 3rem; + position: relative; + right: 8rem; + width: 10em; } } +@media (max-width : 340px) { -.oppia-search-bar-input:focus { - border-color: #66afe9; - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6); - box-shadow: inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6); - outline: 0; -} - -@media (max-width: 629px) { - .oppia-search-bar-category-selector { + :lang(ar) .oppia-search-bar-language-selector .langbtn { + font-size: 13px; + padding-left: 3rem; position: relative; + right: 8rem; + width: 10em; } - /* :lang(ar) .oppia-search-bar-category-selector{ - position: absolute; - } */ } - -/* This rule targets only Firefox browsers. */ -@-moz-document url-prefix() { - .oppia-search-bar-category-selection-symbol { - margin-top: -18px; - padding-top: 16px; +@media (max-width : 285px) { + .classroom-page-input-group.inptcls { + font-size: 13.4px; + width: 13.4em; } -} - -@media (max-width: 500px) { - oppia-search-bar .oppia-input-group.classroom-page-input-group { - width: 17em; + .search-bar-float-start .oppia-search-bar-dropdown-toggle.catbtn { + font-size: 10px; + left: 10rem; + width: 12em; } -} - -@media (max-width: 378px) { - .oppia-search-bar-form { - padding-left: 0; + .oppia-search-bar-language-selector .langbtn { + font-size: 10px; + left: 4.8rem; + width: 12em; } } diff --git a/core/templates/pages/library-page/search-bar/search-bar.component.html b/core/templates/pages/library-page/search-bar/search-bar.component.html index 40ed2ff325121..e855a118f339c 100644 --- a/core/templates/pages/library-page/search-bar/search-bar.component.html +++ b/core/templates/pages/library-page/search-bar/search-bar.component.html @@ -243,76 +243,3 @@ - diff --git a/core/templates/pages/library-page/search-bar/search-bar.component.spec.ts b/core/templates/pages/library-page/search-bar/search-bar.component.spec.ts index 44e3d6ae9e83b..3d3ab6e016266 100644 --- a/core/templates/pages/library-page/search-bar/search-bar.component.spec.ts +++ b/core/templates/pages/library-page/search-bar/search-bar.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for Search bar. */ +// @ts-nocheck + import {EventEmitter, Pipe, PipeTransform} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {HttpClientTestingModule} from '@angular/common/http/testing'; diff --git a/core/templates/pages/library-page/search-bar/search-bar.component.ts b/core/templates/pages/library-page/search-bar/search-bar.component.ts index 7628d178243f0..cec5302cea349 100755 --- a/core/templates/pages/library-page/search-bar/search-bar.component.ts +++ b/core/templates/pages/library-page/search-bar/search-bar.component.ts @@ -31,6 +31,7 @@ import {UrlService} from 'services/contextual/url.service'; import {ConstructTranslationIdsService} from 'services/construct-translation-ids.service'; import {LanguageUtilService} from 'domain/utilities/language-util.service'; import {TranslateService} from '@ngx-translate/core'; +import './search-bar.component.css'; interface SearchDropDownCategories { id: string; diff --git a/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.css b/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.css new file mode 100644 index 0000000000000..ced626aa23b27 --- /dev/null +++ b/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.css @@ -0,0 +1,21 @@ +.oppia-activities-infinity-grid-container { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-left: auto; + margin-right: auto; + max-width: 856px; +} + +.oppia-search-results-loading-message { + background-color: #e9e9e9; + bottom: 0; + color: #000; + height: 30px; + margin-top: 15px; + opacity: 0.9; + padding-top: 5px; + text-align: center; + width: 100%; + z-index: 50; +} diff --git a/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.html b/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.html index 89fe48c81280e..af5081684d89e 100644 --- a/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.html +++ b/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.html @@ -40,27 +40,3 @@ Loading more results - - diff --git a/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.ts b/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.ts index 69fda9c0ba6d9..8e27955cc9fdd 100644 --- a/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.ts +++ b/core/templates/pages/library-page/search-results/activity-tiles-infinity-grid.component.ts @@ -22,10 +22,12 @@ import {Subscription} from 'rxjs'; import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; import {LoaderService} from 'services/loader.service'; import {SearchService} from 'services/search.service'; +import './activity-tiles-infinity-grid.component.css'; @Component({ selector: 'oppia-activity-tiles-infinity-grid', templateUrl: './activity-tiles-infinity-grid.component.html', + styleUrls: ['./activity-tiles-infinity-grid.component.css'], }) export class ActivityTilesInfinityGridComponent { // This property is initialized using Angular lifecycle hooks diff --git a/core/templates/pages/library-page/search-results/search-results.component.css b/core/templates/pages/library-page/search-results/search-results.component.css new file mode 100644 index 0000000000000..6d7bdfbe5df41 --- /dev/null +++ b/core/templates/pages/library-page/search-results/search-results.component.css @@ -0,0 +1,15 @@ +.oppia-search-results-no-match, .oppia-search-results-no-match p { + font-size: 0.85em; + text-align: center; +} +.oppia-search-results-intro { + font-size: 1.6em; + padding-bottom: 40px; + padding-top: 40px; +} +@media (max-width: 800px) { + .oppia-search-results-intro { + margin-left: 20px; + margin-right: 20px; + } +} diff --git a/core/templates/pages/library-page/search-results/search-results.component.html b/core/templates/pages/library-page/search-results/search-results.component.html index 51291327e8ad1..84c71bdc18af9 100644 --- a/core/templates/pages/library-page/search-results/search-results.component.html +++ b/core/templates/pages/library-page/search-results/search-results.component.html @@ -23,20 +23,3 @@ - diff --git a/core/templates/pages/library-page/search-results/search-results.component.ts b/core/templates/pages/library-page/search-results/search-results.component.ts index b7b11e409fe46..278b6fab78677 100644 --- a/core/templates/pages/library-page/search-results/search-results.component.ts +++ b/core/templates/pages/library-page/search-results/search-results.component.ts @@ -24,10 +24,12 @@ import {LoaderService} from 'services/loader.service'; import {SearchService} from 'services/search.service'; import {SiteAnalyticsService} from 'services/site-analytics.service'; import {UserService} from 'services/user.service'; +import './search-results.component.css'; @Component({ selector: 'oppia-search-results', templateUrl: './search-results.component.html', + styleUrls: ['./search-results.component.css'], }) export class SearchResultsComponent { directiveSubscriptions = new Subscription(); diff --git a/core/templates/pages/license-page/license-page.component.css b/core/templates/pages/license-page/license-page.component.css new file mode 100644 index 0000000000000..2a1054615fc51 --- /dev/null +++ b/core/templates/pages/license-page/license-page.component.css @@ -0,0 +1,6 @@ +.oppia-license-page { + margin-bottom: 300px; +} +.oppia-license-page .oppia-static-content { + justify-content: center; +} diff --git a/core/templates/pages/license-page/license-page.component.html b/core/templates/pages/license-page/license-page.component.html index 998a4bec5de7a..a8773b07c1bec 100644 --- a/core/templates/pages/license-page/license-page.component.html +++ b/core/templates/pages/license-page/license-page.component.html @@ -15,12 +15,3 @@

    - - diff --git a/core/templates/pages/license-page/license-page.component.ts b/core/templates/pages/license-page/license-page.component.ts index 1ff28f80d9575..b9b386c30e874 100644 --- a/core/templates/pages/license-page/license-page.component.ts +++ b/core/templates/pages/license-page/license-page.component.ts @@ -17,10 +17,11 @@ */ import {Component} from '@angular/core'; +import './license-page.component.css'; @Component({ selector: 'oppia-license-page', templateUrl: './license-page.component.html', - styleUrls: [], + styleUrls: ['./license-page.component.css'], }) export class LicensePageComponent {} diff --git a/core/templates/pages/login-page/login-page.component.css b/core/templates/pages/login-page/login-page.component.css new file mode 100644 index 0000000000000..0b318621678d8 --- /dev/null +++ b/core/templates/pages/login-page/login-page.component.css @@ -0,0 +1,31 @@ +.login-page { + align-items: center; + display: flex; + height: 400px; + justify-content: center; +} +.login-page .login-card { + max-width: 500px; + min-width: 150px; + width: 100%; +} +.login-page .mat-card-actions { + margin: 0; +} +.login-page .mat-card-header { + margin: 0 -16px; /* Explanation: https://stackoverflow.com/a/64183157/4859885. */ +} +.login-page .mat-form-field { + width: 100%; +} +.login-page /deep/ .mat-form-field-invalid .mat-input-element { + caret-color: #f44336; +} +.login-page /deep/ .mat-error, +.login-page /deep/ .mat-form-field.mat-form-field-invalid .mat-form-field-label { + color: #f44336; +} +.login-page /deep/ .mat-form-field.mat-form-field-invalid .mat-form-field-ripple, +.login-page /deep/ .mat-form-field.mat-form-field-invalid .mat-form-field-ripple.mat-accent { + background-color: #f44336; +} diff --git a/core/templates/pages/login-page/login-page.component.html b/core/templates/pages/login-page/login-page.component.html index 99033ba244296..d750a384097be 100644 --- a/core/templates/pages/login-page/login-page.component.html +++ b/core/templates/pages/login-page/login-page.component.html @@ -42,37 +42,3 @@ - - diff --git a/core/templates/pages/login-page/login-page.component.spec.ts b/core/templates/pages/login-page/login-page.component.spec.ts index 519076c088bbc..7827295d786c3 100644 --- a/core/templates/pages/login-page/login-page.component.spec.ts +++ b/core/templates/pages/login-page/login-page.component.spec.ts @@ -15,6 +15,7 @@ /** * @fileoverview Unit tests for the login page. */ +// @ts-nocheck import { ComponentFixture, diff --git a/core/templates/pages/login-page/login-page.component.ts b/core/templates/pages/login-page/login-page.component.ts index e21b37c847669..05a17e7b7c1be 100644 --- a/core/templates/pages/login-page/login-page.component.ts +++ b/core/templates/pages/login-page/login-page.component.ts @@ -26,10 +26,12 @@ import {AuthService} from 'services/auth.service'; import {LoaderService} from 'services/loader.service'; import {UserService} from 'services/user.service'; import {WindowRef} from 'services/contextual/window-ref.service'; +import './login-page.component.css'; @Component({ selector: 'login-page', templateUrl: './login-page.component.html', + styleUrls: ['./login-page.component.css'], }) export class LoginPageComponent implements OnInit { email = new FormControl('', [Validators.email]); diff --git a/core/templates/pages/maintenance-page/maintenance-page.component.css b/core/templates/pages/maintenance-page/maintenance-page.component.css new file mode 100644 index 0000000000000..0c5fa93e34e34 --- /dev/null +++ b/core/templates/pages/maintenance-page/maintenance-page.component.css @@ -0,0 +1,9 @@ +.oppia-long-text { + display: flex; + flex-direction: column; + padding: 30px 55px 30px 45px; +} + +.oppia-navbar { + background-color: #00645c; +} diff --git a/core/templates/pages/maintenance-page/maintenance-page.component.html b/core/templates/pages/maintenance-page/maintenance-page.component.html index 85119000e3757..e786d6382c576 100644 --- a/core/templates/pages/maintenance-page/maintenance-page.component.html +++ b/core/templates/pages/maintenance-page/maintenance-page.component.html @@ -37,14 +37,3 @@

    The Oppia site is temporarily unavailable.

    - diff --git a/core/templates/pages/maintenance-page/maintenance-page.component.ts b/core/templates/pages/maintenance-page/maintenance-page.component.ts index 18b0a18dfacea..beb1eca88fdc6 100644 --- a/core/templates/pages/maintenance-page/maintenance-page.component.ts +++ b/core/templates/pages/maintenance-page/maintenance-page.component.ts @@ -19,11 +19,12 @@ import {Component, OnInit} from '@angular/core'; import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service'; import {DocumentAttributeCustomizationService} from 'services/contextual/document-attribute-customization.service'; +import './maintenance-page.component.css'; @Component({ selector: 'oppia-maintenance-page', templateUrl: './maintenance-page.component.html', - styleUrls: [], + styleUrls: ['./maintenance-page.component.css'], }) export class MaintenancePageComponent implements OnInit { currentLang: string = 'en'; diff --git a/core/templates/pages/moderator-page/moderator-page.component.css b/core/templates/pages/moderator-page/moderator-page.component.css new file mode 100644 index 0000000000000..d4dee871932b7 --- /dev/null +++ b/core/templates/pages/moderator-page/moderator-page.component.css @@ -0,0 +1,8 @@ +table.oppia-padded-table { + border: 1px solid black; + padding: 5px; +} +table.oppia-padded-table th, table.oppia-padded-table td { + border: 1px solid black; + padding: 5px; +} diff --git a/core/templates/pages/moderator-page/moderator-page.component.html b/core/templates/pages/moderator-page/moderator-page.component.html index 738e5a3f50fa6..3eacd77c04715 100644 --- a/core/templates/pages/moderator-page/moderator-page.component.html +++ b/core/templates/pages/moderator-page/moderator-page.component.html @@ -89,13 +89,3 @@

    {{ 'I18N_PARTNERSHIPS_PAGE_FREQUENTLY_ASKED' | - - diff --git a/core/templates/pages/partnerships-page/partnerships-page.component.ts b/core/templates/pages/partnerships-page/partnerships-page.component.ts index 8be68c1b32872..119e5f490808b 100644 --- a/core/templates/pages/partnerships-page/partnerships-page.component.ts +++ b/core/templates/pages/partnerships-page/partnerships-page.component.ts @@ -23,11 +23,12 @@ import {Subscription} from 'rxjs'; import {PageTitleService} from 'services/page-title.service'; import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service'; import {AppConstants} from 'app.constants'; +import './partnerships-page.component.css'; @Component({ selector: 'partnerships-page', templateUrl: './partnerships-page.component.html', - styleUrls: [], + styleUrls: ['./partnerships-page.component.css'], }) export class PartnershipsPageComponent implements OnInit, OnDestroy { directiveSubscriptions = new Subscription(); diff --git a/core/templates/pages/practice-session-page/practice-session-page.component.css b/core/templates/pages/practice-session-page/practice-session-page.component.css new file mode 100644 index 0000000000000..56fd7935b32e6 --- /dev/null +++ b/core/templates/pages/practice-session-page/practice-session-page.component.css @@ -0,0 +1,45 @@ +.oppia-practice-session-container .oppia-practice-session { + color: #01645c; + font-family: 'Capriola', 'Roboto', Arial, sans-serif; + font-size: 3em; + margin-bottom: 25px; + margin-top: 0; + padding-top: 66.5px; + text-align: center; +} + +.oppia-practice-session-container { + color: black; + font-family: 'Roboto', Arial, sans-serif; + margin: 0 auto; + text-decoration: none; +} + +.new-oppia-practice-session-container { + background-color: #eeebea; + height: 100vh; + overflow: hidden; + z-index: 0; +} + +.new-oppia-practice-session-container::before { + background-image: url('/assets/images/background/conversation_skin.svg'); + background-position: center; + content: ''; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: -1; +} + +@media(max-width: 925px) { + .oppia-practice-session-container { + max-width: 95%; + } + + .new-oppia-practice-session-container { + max-width: 100%; + } +} diff --git a/core/templates/pages/practice-session-page/practice-session-page.component.html b/core/templates/pages/practice-session-page/practice-session-page.component.html index e00f9abb5d72a..fdc46114ec0e5 100644 --- a/core/templates/pages/practice-session-page/practice-session-page.component.html +++ b/core/templates/pages/practice-session-page/practice-session-page.component.html @@ -6,52 +6,3 @@ [questionPlayerConfig]="questionPlayerConfig"> - - diff --git a/core/templates/pages/practice-session-page/practice-session-page.component.ts b/core/templates/pages/practice-session-page/practice-session-page.component.ts index 04ce670bbe5c5..e2d106d34bda0 100644 --- a/core/templates/pages/practice-session-page/practice-session-page.component.ts +++ b/core/templates/pages/practice-session-page/practice-session-page.component.ts @@ -28,6 +28,7 @@ import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; import {PageTitleService} from 'services/page-title.service'; import {PracticeSessionsBackendApiService} from './practice-session-backend-api.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import './practice-session-page.component.css'; enum PracticeSessionType { Lesson = 'lesson', @@ -39,6 +40,7 @@ enum PracticeSessionType { @Component({ selector: 'practice-session-page', templateUrl: './practice-session-page.component.html', + styleUrls: ['./practice-session-page.component.css'], }) export class PracticeSessionPageComponent implements OnInit, OnDestroy { directiveSubscriptions = new Subscription(); diff --git a/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.css b/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.css new file mode 100644 index 0000000000000..9a147005b7bfd --- /dev/null +++ b/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.css @@ -0,0 +1,3 @@ +.language-field { + width: 100%; +} diff --git a/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.html b/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.html index 530c19a76c37b..02c4ae6a0a91e 100644 --- a/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.html +++ b/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.html @@ -15,9 +15,3 @@ - - diff --git a/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.spec.ts b/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.spec.ts index d06ceb490e769..64e9574edc3e7 100644 --- a/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.spec.ts +++ b/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.spec.ts @@ -16,8 +16,10 @@ * @fileoverview Unit tests for the preferred site language component. */ +// @ts-nocheck + import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {FormsModule} from '@angular/forms'; +import {FormsModule, NG_VALUE_ACCESSOR} from '@angular/forms'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {MaterialModule} from 'modules/material.module'; import {MockTranslatePipe} from 'tests/unit-test-utils'; @@ -93,4 +95,10 @@ describe('Preferred Site Language Selector Component', () => { componentInstance.registerOnTouched(fn); expect(componentInstance.onTouched).toBe(fn); }); + + it('should expose itself as a control value accessor', () => { + expect(fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)).toEqual([ + componentInstance, + ]); + }); }); diff --git a/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.ts b/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.ts index a3a41d66fb78a..392a5f35d6398 100644 --- a/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.ts +++ b/core/templates/pages/preferences-page/form-fields/preferred-language-selector.component.ts @@ -18,6 +18,7 @@ import {Component, forwardRef, Input} from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import './preferred-language-selector.component.css'; interface Language { id: string; text: string; @@ -27,6 +28,7 @@ interface Language { @Component({ selector: 'oppia-preferred-language-selector', templateUrl: './preferred-language-selector.component.html', + styleUrls: ['./preferred-language-selector.component.css'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/core/templates/pages/preferences-page/form-fields/preferred-languages.component.css b/core/templates/pages/preferences-page/form-fields/preferred-languages.component.css new file mode 100644 index 0000000000000..4e9f0f93965ee --- /dev/null +++ b/core/templates/pages/preferences-page/form-fields/preferred-languages.component.css @@ -0,0 +1,3 @@ +.preferred-languages-chip-list { + width: 100%; +} diff --git a/core/templates/pages/preferences-page/form-fields/preferred-languages.component.html b/core/templates/pages/preferences-page/form-fields/preferred-languages.component.html index 5e38e94532a64..9533030e4215c 100644 --- a/core/templates/pages/preferences-page/form-fields/preferred-languages.component.html +++ b/core/templates/pages/preferences-page/form-fields/preferred-languages.component.html @@ -32,9 +32,3 @@ - - diff --git a/core/templates/pages/preferences-page/form-fields/preferred-languages.component.spec.ts b/core/templates/pages/preferences-page/form-fields/preferred-languages.component.spec.ts index 308fc1c726694..439268912fdc1 100644 --- a/core/templates/pages/preferences-page/form-fields/preferred-languages.component.spec.ts +++ b/core/templates/pages/preferences-page/form-fields/preferred-languages.component.spec.ts @@ -16,9 +16,16 @@ * @fileoverview Unit tests for the preferred languages component. */ +// @ts-nocheck + import {ElementRef} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import { + FormControl, + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, +} from '@angular/forms'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {MaterialModule} from 'modules/material.module'; import {MockTranslatePipe} from 'tests/unit-test-utils'; @@ -184,4 +191,10 @@ describe('Preferred Languages Component', () => { componentInstance.registerOnTouched(fn); expect(componentInstance.onTouched).toBe(fn); }); + + it('should expose itself as a control value accessor', () => { + expect(fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)).toEqual([ + componentInstance, + ]); + }); }); diff --git a/core/templates/pages/preferences-page/form-fields/preferred-languages.component.ts b/core/templates/pages/preferences-page/form-fields/preferred-languages.component.ts index c70b840ae828b..fd9ffa1e61ce6 100644 --- a/core/templates/pages/preferences-page/form-fields/preferred-languages.component.ts +++ b/core/templates/pages/preferences-page/form-fields/preferred-languages.component.ts @@ -32,10 +32,12 @@ import { } from '@angular/forms'; import {MatChipList} from '@angular/material/chips'; import {LanguageIdAndText} from 'domain/utilities/language-util.service'; +import './preferred-languages.component.css'; @Component({ selector: 'oppia-preferred-languages', templateUrl: './preferred-languages.component.html', + styleUrls: ['./preferred-languages.component.css'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/core/templates/pages/preferences-page/form-fields/subject-interests.component.css b/core/templates/pages/preferences-page/form-fields/subject-interests.component.css new file mode 100644 index 0000000000000..2ad10b19bad87 --- /dev/null +++ b/core/templates/pages/preferences-page/form-fields/subject-interests.component.css @@ -0,0 +1,3 @@ +.subject-interests-chip-list { + width: 100%; +} diff --git a/core/templates/pages/preferences-page/form-fields/subject-interests.component.html b/core/templates/pages/preferences-page/form-fields/subject-interests.component.html index 63fe9b9501883..4d139aed891b8 100644 --- a/core/templates/pages/preferences-page/form-fields/subject-interests.component.html +++ b/core/templates/pages/preferences-page/form-fields/subject-interests.component.html @@ -34,9 +34,3 @@ {{ 'I18N_PREFERENCES_SUBJECT_INTERESTS_ERROR_TEXT' | translate }} - - diff --git a/core/templates/pages/preferences-page/form-fields/subject-interests.component.spec.ts b/core/templates/pages/preferences-page/form-fields/subject-interests.component.spec.ts index 79e0ab45f3e8d..dae6c4330985c 100644 --- a/core/templates/pages/preferences-page/form-fields/subject-interests.component.spec.ts +++ b/core/templates/pages/preferences-page/form-fields/subject-interests.component.spec.ts @@ -16,9 +16,16 @@ * @fileoverview Unit tests for the subject interests component. */ +// @ts-nocheck + import {ElementRef} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import { + FormControl, + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, +} from '@angular/forms'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {MaterialModule} from 'modules/material.module'; import {MockTranslatePipe} from 'tests/unit-test-utils'; @@ -183,4 +190,10 @@ describe('Subject interests form field Component', () => { expect(componentInstance.formCtrl.pristine).toBeTrue(); expect(componentInstance.onChange).not.toHaveBeenCalled(); }); + + it('should expose itself as a control value accessor', () => { + expect(fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)).toEqual([ + componentInstance, + ]); + }); }); diff --git a/core/templates/pages/preferences-page/form-fields/subject-interests.component.ts b/core/templates/pages/preferences-page/form-fields/subject-interests.component.ts index e7fce4e6aef9d..0566ddd1a5eec 100644 --- a/core/templates/pages/preferences-page/form-fields/subject-interests.component.ts +++ b/core/templates/pages/preferences-page/form-fields/subject-interests.component.ts @@ -33,10 +33,12 @@ import {MatChipList} from '@angular/material/chips'; import cloneDeep from 'lodash/cloneDeep'; import {Observable} from 'rxjs'; import {map, startWith} from 'rxjs/operators'; +import './subject-interests.component.css'; @Component({ selector: 'oppia-subject-interests', templateUrl: './subject-interests.component.html', + styleUrls: ['./subject-interests.component.css'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.css b/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.css new file mode 100644 index 0000000000000..3d56e067c42b5 --- /dev/null +++ b/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.css @@ -0,0 +1,26 @@ +.oppia-editor-change-profile-picture-container .oppia-profile-picture-crop-area { + background: #e4e4e4; + height: 350px; + margin-top: 20px; + max-width: 100%; + position: relative; + width: 500px; +} +.oppia-editor-change-profile-picture-container .oppia-profile-picture-reset-button { + position: absolute; + right: -36px; + top: 0; +} +.oppia-editor-change-profile-picture-container .oppia-profile-image-uploader-container { + min-height: 300px; +} +.oppia-editor-change-profile-picture-container .oppia-form-error-text { + margin-top: 15px; +} +.oppia-unsupported-svg-tag-attribute-error { + background-color: #fcf8e3; + border-color: #faebcc; + color: #8a6d3b; + margin-top: 15px; + padding: 10px; +} diff --git a/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.html b/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.html index 932dad54ce0aa..425c548dc6409 100644 --- a/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.html +++ b/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.html @@ -46,31 +46,3 @@

    {{ 'I18N_PREFERENCES_PROFILE_PICTURE_UPLOAD' | translate }}

    - diff --git a/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.ts b/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.ts index 1ec40db058441..66c4843e3527b 100644 --- a/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.ts +++ b/core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.ts @@ -29,11 +29,13 @@ import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-e import Cropper from 'cropperjs'; import {SvgSanitizerService} from 'services/svg-sanitizer.service'; import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; +import './edit-profile-picture-modal.component.css'; require('cropperjs/dist/cropper.min.css'); @Component({ selector: 'oppia-edit-profile-picture-modal', templateUrl: './edit-profile-picture-modal.component.html', + styleUrls: ['./edit-profile-picture-modal.component.css'], }) export class EditProfilePictureModalComponent extends ConfirmOrCancelModal { // 'uploadedImage' will be null if the uploaded svg is invalid or not trusted. diff --git a/core/templates/pages/privacy-page/privacy-page.component.css b/core/templates/pages/privacy-page/privacy-page.component.css new file mode 100644 index 0000000000000..80af292346140 --- /dev/null +++ b/core/templates/pages/privacy-page/privacy-page.component.css @@ -0,0 +1,3 @@ +a { + text-decoration: underline; +} diff --git a/core/templates/pages/privacy-page/privacy-page.component.html b/core/templates/pages/privacy-page/privacy-page.component.html index 67f4098a382b7..b30aa9892b832 100644 --- a/core/templates/pages/privacy-page/privacy-page.component.html +++ b/core/templates/pages/privacy-page/privacy-page.component.html @@ -409,9 +409,3 @@

    Contacting Us

    Last updated: 24 May 2018 - - diff --git a/core/templates/pages/privacy-page/privacy-page.component.ts b/core/templates/pages/privacy-page/privacy-page.component.ts index c9e9318302668..f19268ea72919 100644 --- a/core/templates/pages/privacy-page/privacy-page.component.ts +++ b/core/templates/pages/privacy-page/privacy-page.component.ts @@ -17,9 +17,11 @@ */ import {Component} from '@angular/core'; +import './privacy-page.component.css'; @Component({ selector: 'oppia-privacy-page', templateUrl: './privacy-page.component.html', + styleUrls: ['./privacy-page.component.css'], }) export class PrivacyPageComponent {} diff --git a/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.css b/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.css new file mode 100644 index 0000000000000..2eef3a74e0092 --- /dev/null +++ b/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.css @@ -0,0 +1,57 @@ +.oppia-beam-jobs-tab-page { + height: 100%; + max-width: 900px; +} +.oppia-beam-jobs-tab-page .job-name-form-container { + display: flex; + width: 100%; +} +.oppia-beam-jobs-tab-page .job-name-form { + flex-grow: 1; +} +.oppia-beam-jobs-tab-page .job-start-button { + flex-grow: 0; + margin-left: 26px; + margin-top: 6px; +} +.oppia-beam-jobs-tab-page .job-runs-table-container { + max-height: 800px; + min-height: 300px; + overflow: auto; + width: 100%; +} +.oppia-beam-jobs-tab-page .job-runs-table { + font-size: 30px; + width: 100%; +} +.oppia-beam-jobs-tab-page .job-name-cell { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} +.oppia-beam-jobs-tab-page .mat-icon.mat-warn { + color: #f44336; +} +.oppia-beam-jobs-tab-page .mat-raised-button { + width: 100%; +} +.oppia-beam-jobs-tab-page .mat-raised-button.mat-warn:not(.mat-button-disabled) { + background-color: #f44336; + color: #fff; +} +.oppia-beam-jobs-tab-page .job-run-action-column { + width: 150px; +} +.oppia-beam-jobs-tab-page .job-status-mat-icon { + font-size: 30px; + height: 30px; + width: 30px; +} +.oppia-beam-jobs-tab-page .job-run-terminal-actions { + align-items: center; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0; +} +.oppia-beam-jobs-tab-page .job-run-terminal-actions a { + width: 100%; +} diff --git a/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.html b/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.html index b8a1fd33a249b..98b6dd7b8351d 100644 --- a/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.html +++ b/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.html @@ -108,63 +108,3 @@ - - diff --git a/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.spec.ts b/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.spec.ts index aa19ab054402d..328887c495f09 100644 --- a/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.spec.ts +++ b/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for BeamJobsTabComponent. */ +// @ts-nocheck + import {ClipboardModule} from '@angular/cdk/clipboard'; import {HarnessLoader} from '@angular/cdk/testing'; import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; @@ -62,7 +64,7 @@ describe('Beam Jobs Tab Component', () => { let component: BeamJobsTabComponent; let loader: HarnessLoader; - let backendApiService: ReleaseCoordinatorBackendApiService; + let backendApiService: jasmine.SpyObj; const fooJob = new BeamJob('FooJob'); const barJob = new BeamJob('BarJob'); @@ -102,19 +104,16 @@ describe('Beam Jobs Tab Component', () => { providers: [ { provide: ReleaseCoordinatorBackendApiService, - useValue: jasmine.createSpyObj( + useValue: jasmine.createSpyObj( 'ReleaseCoordinatorBackendApiService', - {}, - { - getBeamJobs: () => of(beamJobs), - getBeamJobRuns: () => of(beamJobRuns), - startNewBeamJob: () => - of(new BeamJobRun('123', 'FooJob', 'RUNNING', 0, 0, false)), - cancelBeamJobRun: () => - of(new BeamJobRun('123', 'FooJob', 'CANCELLED', 0, 0, false)), - getBeamJobRunOutput: () => of(new BeamJobRunResult('abc', '123')), - } as Partial - ), + [ + 'getBeamJobs', + 'getBeamJobRuns', + 'startNewBeamJob', + 'cancelBeamJobRun', + 'getBeamJobRunOutput', + ] + ) as jasmine.SpyObj, }, ], }); @@ -132,7 +131,20 @@ describe('Beam Jobs Tab Component', () => { await TestBed.compileComponents(); - backendApiService = TestBed.inject(ReleaseCoordinatorBackendApiService); + backendApiService = TestBed.inject( + ReleaseCoordinatorBackendApiService + ) as unknown as jasmine.SpyObj; + backendApiService.getBeamJobs.and.returnValue(of(beamJobs)); + backendApiService.getBeamJobRuns.and.returnValue(of(beamJobRuns)); + backendApiService.startNewBeamJob.and.returnValue( + of(new BeamJobRun('123', 'FooJob', 'RUNNING', 0, 0, false)) + ); + backendApiService.cancelBeamJobRun.and.returnValue( + of(new BeamJobRun('123', 'FooJob', 'CANCELLED', 0, 0, false)) + ); + backendApiService.getBeamJobRunOutput.and.returnValue( + of(new BeamJobRunResult('abc', '123')) + ); fixture = TestBed.createComponent(BeamJobsTabComponent); component = fixture.componentInstance; @@ -148,10 +160,8 @@ describe('Beam Jobs Tab Component', () => { const beamJobRunsOutput = m.hot('^---r-|', {r: beamJobRuns}); const expectedNames = ' e---n--'; const expectedRuns = ' e---r--'; - spyOn(backendApiService, 'getBeamJobs').and.returnValue(beamJobsOutput); - spyOn(backendApiService, 'getBeamJobRuns').and.returnValue( - beamJobRunsOutput - ); + backendApiService.getBeamJobs.and.returnValue(beamJobsOutput); + backendApiService.getBeamJobRuns.and.returnValue(beamJobRunsOutput); fixture.detectChanges(); @@ -173,7 +183,7 @@ describe('Beam Jobs Tab Component', () => { marbles(m => { const beamJobs = m.hot('^-#', undefined, new Error('err')); const expectedNames = ' e-e'; - spyOn(backendApiService, 'getBeamJobs').and.returnValue(beamJobs); + backendApiService.getBeamJobs.and.returnValue(beamJobs); fixture.detectChanges(); m.expect(component.jobNames).toBeObservable(expectedNames, {e: []}); @@ -187,7 +197,7 @@ describe('Beam Jobs Tab Component', () => { marbles(m => { const beamJobRuns = m.hot('^-#', undefined, new Error('err')); const expectedRuns = ' e-e'; - spyOn(backendApiService, 'getBeamJobRuns').and.returnValue(beamJobRuns); + backendApiService.getBeamJobRuns.and.returnValue(beamJobRuns); fixture.detectChanges(); m.expect(component.beamJobRuns).toBeObservable(expectedRuns, {e: []}); @@ -255,10 +265,7 @@ describe('Beam Jobs Tab Component', () => { false, null ); - const startNewJobSpy = spyOn( - backendApiService, - 'startNewBeamJob' - ).and.returnValue(of(newPendingFooJob)); + backendApiService.startNewBeamJob.and.returnValue(of(newPendingFooJob)); await input.setValue('FooJob'); await autocomplete.selectOption({text: 'FooJob'}); @@ -283,7 +290,7 @@ describe('Beam Jobs Tab Component', () => { await confirmButton.click(); await fixture.whenStable(); - expect(startNewJobSpy).toHaveBeenCalledWith(fooJob); + expect(backendApiService.startNewBeamJob).toHaveBeenCalledWith(fooJob); expect((await loader.getAllHarnesses(MatDialogHarness)).length).toEqual(0); expect(component.beamJobRuns.value).toContain(newPendingFooJob); @@ -302,10 +309,7 @@ describe('Beam Jobs Tab Component', () => { 0, false ); - const cancelBeamJobRunSpy = spyOn( - backendApiService, - 'cancelBeamJobRun' - ).and.returnValue(of(cancellingFooJob)); + backendApiService.cancelBeamJobRun.and.returnValue(of(cancellingFooJob)); await input.setValue('FooJob'); await autocomplete.selectOption({text: 'FooJob'}); @@ -331,7 +335,9 @@ describe('Beam Jobs Tab Component', () => { await confirmButton.click(); await fixture.whenStable(); - expect(cancelBeamJobRunSpy).toHaveBeenCalledWith(runningFooJob); + expect(backendApiService.cancelBeamJobRun).toHaveBeenCalledWith( + runningFooJob + ); expect((await loader.getAllHarnesses(MatDialogHarness)).length).toEqual(0); expect(component.beamJobRuns.value).not.toContain(runningFooJob); expect(component.beamJobRuns.value).toContain(cancellingFooJob); @@ -343,10 +349,9 @@ describe('Beam Jobs Tab Component', () => { const autocomplete = await loader.getHarness(MatAutocompleteHarness); const input = await loader.getHarness(MatInputHarness); - const getBeamJobRunOutputSpy = spyOn( - backendApiService, - 'getBeamJobRunOutput' - ).and.returnValue(of(new BeamJobRunResult('Lorem Ipsum', ''))); + backendApiService.getBeamJobRunOutput.and.returnValue( + of(new BeamJobRunResult('Lorem Ipsum', '')) + ); await input.setValue('BarJob'); await autocomplete.selectOption({text: 'BarJob'}); @@ -364,7 +369,9 @@ describe('Beam Jobs Tab Component', () => { expect(await dialogHost.text()).toContain('Lorem Ipsum'); await dialog.close(); - expect(getBeamJobRunOutputSpy).toHaveBeenCalledWith(doneBarJob); + expect(backendApiService.getBeamJobRunOutput).toHaveBeenCalledWith( + doneBarJob + ); expect((await loader.getAllHarnesses(MatDialogHarness)).length).toEqual(0); component.ngOnDestroy(); @@ -423,41 +430,37 @@ describe('Beam Jobs Tab Component', () => { }); it('should refresh the beam job runs every 15 seconds', fakeAsync(() => { - const getBeamJobRunsSpy = spyOn( - backendApiService, - 'getBeamJobRuns' - ).and.returnValue(of(beamJobRuns)); + backendApiService.getBeamJobRuns.calls.reset(); + backendApiService.getBeamJobRuns.and.returnValue(of(beamJobRuns)); fixture.detectChanges(); // The first time is called by ngOnInit(). - expect(getBeamJobRunsSpy).toHaveBeenCalledTimes(1); + expect(backendApiService.getBeamJobRuns).toHaveBeenCalledTimes(1); tick(BeamJobsTabComponent.BEAM_JOB_RUNS_REFRESH_INTERVAL_MSECS); fixture.detectChanges(); // The second time is called out by our interval refresh timer. - expect(getBeamJobRunsSpy).toHaveBeenCalledTimes(2); + expect(backendApiService.getBeamJobRuns).toHaveBeenCalledTimes(2); component.ngOnDestroy(); })); it('should not refresh beam jobs if all jobs are terminal', fakeAsync(() => { - const getBeamJobRunsSpy = spyOn( - backendApiService, - 'getBeamJobRuns' - ).and.returnValue(of(terminalBeamJobRuns)); + backendApiService.getBeamJobRuns.calls.reset(); + backendApiService.getBeamJobRuns.and.returnValue(of(terminalBeamJobRuns)); fixture.detectChanges(); // The first time is called by ngOnInit(). - expect(getBeamJobRunsSpy).toHaveBeenCalledTimes(1); + expect(backendApiService.getBeamJobRuns).toHaveBeenCalledTimes(1); tick(BeamJobsTabComponent.BEAM_JOB_RUNS_REFRESH_INTERVAL_MSECS); fixture.detectChanges(); // The second time should not be called out, because all jobs are terminal. - expect(getBeamJobRunsSpy).toHaveBeenCalledTimes(1); + expect(backendApiService.getBeamJobRuns).toHaveBeenCalledTimes(1); component.ngOnDestroy(); })); diff --git a/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.ts b/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.ts index 70c67b7e3d0b8..badf6d296a1b6 100644 --- a/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.ts +++ b/core/templates/pages/release-coordinator-page/beam-jobs-tab/beam-jobs-tab.component.ts @@ -47,10 +47,12 @@ import {StartNewBeamJobDialogComponent} from 'pages/release-coordinator-page/com import {ViewBeamJobOutputDialogComponent} from 'pages/release-coordinator-page/components/view-beam-job-output-dialog.component'; import {ReleaseCoordinatorBackendApiService} from 'pages/release-coordinator-page/services/release-coordinator-backend-api.service'; import {AlertsService} from 'services/alerts.service'; +import './beam-jobs-tab.component.css'; @Component({ selector: 'oppia-beam-jobs-tab', templateUrl: './beam-jobs-tab.component.html', + styleUrls: ['./beam-jobs-tab.component.css'], }) export class BeamJobsTabComponent implements OnInit, OnDestroy { static readonly BEAM_JOB_RUNS_REFRESH_INTERVAL_MSECS = 15000; diff --git a/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.css b/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.css new file mode 100644 index 0000000000000..8e2aca75b958b --- /dev/null +++ b/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.css @@ -0,0 +1,34 @@ +.cancel-beam-job-dialog .mat-raised-button.mat-warn:not(.mat-button-disabled) { + background-color: #f44336; + color: #fff; +} +.cancel-beam-job-dialog .mat-progress-bar.mat-warn /deep/ .mat-progress-bar-fill::after { + background-color: #f44336; +} +.cancel-beam-job-dialog .mat-progress-bar.mat-warn /deep/ .mat-progress-bar-buffer { + background: #ff7961; +} +.cancel-beam-job-dialog .beam-job-run-summary-card { + border: 1px solid rgba(0, 0, 0, 0.12); + margin-bottom: 20px; +} +.cancel-beam-job-dialog .beam-job-run-summary-card mat-card-header { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} +.cancel-beam-job-dialog .beam-job-run-summary-card mat-card-title { + font-weight: bold; +} +.cancel-beam-job-dialog .beam-job-run-summary-icon { + font-size: 40px; + margin-top: 8px; + width: 40px; +} +.cancel-beam-job-dialog .beam-job-run-summary-table td, +.cancel-beam-job-dialog .beam-job-run-summary-table th { + padding: 10px; +} +.cancel-beam-job-dialog .beam-job-actions { + display: flex; + justify-content: space-between; + width: 100%; +} diff --git a/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.html b/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.html index 95c8122947b1e..ed02562ff9f43 100644 --- a/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.html +++ b/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.html @@ -35,40 +35,3 @@ - - diff --git a/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.ts b/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.ts index 9e42a08cbdc1c..648595589b32d 100644 --- a/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.ts +++ b/core/templates/pages/release-coordinator-page/components/cancel-beam-job-dialog.component.ts @@ -24,10 +24,12 @@ import {BeamJobRun} from 'domain/jobs/beam-job-run.model'; import {ReleaseCoordinatorBackendApiService} from 'pages/release-coordinator-page/services/release-coordinator-backend-api.service'; import {AlertsService} from 'services/alerts.service'; import {of} from 'rxjs'; +import './cancel-beam-job-dialog.component.css'; @Component({ selector: 'cancel-beam-job-dialog', templateUrl: './cancel-beam-job-dialog.component.html', + styleUrls: ['./cancel-beam-job-dialog.component.css'], }) export class CancelBeamJobDialogComponent { isRunning = false; diff --git a/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.css b/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.css new file mode 100644 index 0000000000000..32d1579ffa63e --- /dev/null +++ b/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.css @@ -0,0 +1,13 @@ +.start-new-beam-job-dialog .actions { + display: flex; + justify-content: space-between; + width: 100%; +} +.start-new-beam-job-dialog .job-name { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + font-weight: bold; +} +.start-new-beam-job-dialog .warning { + color: red; + font-weight: bold; +} diff --git a/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.html b/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.html index bcdca8b24c660..c39882e524234 100644 --- a/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.html +++ b/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.html @@ -34,19 +34,3 @@

    Start new {{beamJob.name}}? - - diff --git a/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.ts b/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.ts index 7ec14a71df765..9633911806fd9 100644 --- a/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.ts +++ b/core/templates/pages/release-coordinator-page/components/start-new-beam-job-dialog.component.ts @@ -26,10 +26,12 @@ import {ReleaseCoordinatorBackendApiService} from 'pages/release-coordinator-pag import {AlertsService} from 'services/alerts.service'; import {of} from 'rxjs'; import {AppConstants} from 'app.constants'; +import './start-new-beam-job-dialog.component.css'; @Component({ selector: 'start-new-beam-job-dialog', templateUrl: './start-new-beam-job-dialog.component.html', + styleUrls: ['./start-new-beam-job-dialog.component.css'], }) export class StartNewBeamJobDialogComponent { readonly DEV_MODE: boolean = AppConstants.DEV_MODE; diff --git a/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.css b/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.css new file mode 100644 index 0000000000000..a557b57ef0e85 --- /dev/null +++ b/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.css @@ -0,0 +1,50 @@ +.view-beam-job-output-dialog .beam-job-run-summary-card { + border: 1px solid rgba(0, 0, 0, 0.12); + margin-bottom: 20px; +} +.view-beam-job-output-dialog .beam-job-run-summary-card mat-card-header { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} +.view-beam-job-output-dialog .beam-job-run-summary-card mat-card-title { + font-weight: bold; +} +.view-beam-job-output-dialog .beam-job-run-summary-icon { + font-size: 40px; + margin-top: 8px; + width: 40px; +} +.view-beam-job-output-dialog .beam-job-run-summary-table td, +.view-beam-job-output-dialog .beam-job-run-summary-table th { + padding: 10px; +} +.view-beam-job-output-dialog .beam-job-run-failure-message { + padding: 1em 0; + text-align: center; +} +.view-beam-job-output-dialog .beam-job-run-output { + background-color: rgba(0, 0, 0, 0.06); + max-height: 300px; + overflow-x: auto; + overflow-y: auto; + padding: 1em; + white-space: pre; + word-wrap: normal; +} +.view-beam-job-output-dialog .beam-job-run-empty-output { + background-color: rgba(0, 0, 0, 0.06); + max-height: 300px; + overflow-x: auto; + overflow-y: auto; + padding: 1em; + text-align: center; + white-space: pre; + word-wrap: normal; +} +.view-beam-job-output-dialog .beam-job-run-output-actions { + display: flex; + justify-content: space-between; + width: 100%; +} +.view-beam-job-output-dialog .mat-icon.mat-warn { + color: #f44336; +} diff --git a/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.html b/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.html index b9ca8829c6818..15136ed7afd46 100644 --- a/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.html +++ b/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.html @@ -69,56 +69,3 @@ - - diff --git a/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.ts b/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.ts index 7674f070ded8e..7742776f1903e 100644 --- a/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.ts +++ b/core/templates/pages/release-coordinator-page/components/view-beam-job-output-dialog.component.ts @@ -26,10 +26,12 @@ import {BeamJobRunResult} from 'domain/jobs/beam-job-run-result.model'; import {BeamJobRun} from 'domain/jobs/beam-job-run.model'; import {ReleaseCoordinatorBackendApiService} from 'pages/release-coordinator-page/services/release-coordinator-backend-api.service'; import {AlertsService} from 'services/alerts.service'; +import './view-beam-job-output-dialog.component.css'; @Component({ selector: 'view-beam-job-output-dialog', templateUrl: './view-beam-job-output-dialog.component.html', + styleUrls: ['./view-beam-job-output-dialog.component.css'], }) export class ViewBeamJobOutputDialogComponent implements OnInit, OnDestroy { selectedTab = new FormControl(0); diff --git a/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.css b/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.css new file mode 100644 index 0000000000000..421e3b81d614d --- /dev/null +++ b/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.css @@ -0,0 +1,158 @@ +.info-button { + margin-left: -39px; + margin-top: 18px; +} + +.oppia-feature-configuration-warning-icon { + color: #827127; +} + +.oppia-feature-configuration-warning { + background: #fff5c8; + border: 0.0625rem solid #827127; + border-radius: 0.25rem; + color: #333; + margin-top: 1rem; + padding-left: 0.25rem; +} + +.oppia-features .oppia-flex-center { + align-items: center; + display: flex; + justify-content: center; +} + +.oppia-features .oppia-flex-horizon-center { + display: flex; + justify-content: center; +} + +.oppia-features .oppia-flex-vertical-center { + align-items: center; + display: flex; +} + +.oppia-features .oppia-control-buttons-container { + align-items: center; + border-top: 1px solid #dee2e6; + display: flex; + justify-content: flex-end; + padding-top: 10px; +} + +.oppia-features .oppia-control-buttons-container button { + margin-left: 5px; +} + +.oppia-features .oppia-conjunction-indicator { + align-items: center; + display: flex; + font-size: 12px; + justify-content: space-between; + margin-bottom: 10px; + margin-top: 10px; + width: 100%; +} + +.oppia-features .oppia-conjunction-indicator > .horizontal-line { + border-bottom: 1px dotted #dee2e6; + flex-grow: 1; + height: 0; +} + +.oppia-features .oppia-input-error { + margin-left: 15px; + position: relative; +} + +.oppia-features .oppia-input-error .input-error-symbol { + color: #ff6347; + font-weight: bold; +} + +.oppia-features .oppia-input-error .tooltip { + background-color: #f5f5f5; + border: 1px solid #ff6347; + border-radius: 3px; + display: none; + font-size: 14px; + left: -50%; + opacity: 0; + padding: 5px; + position: absolute; + width: 200px; +} + +.oppia-features .oppia-input-error:hover .tooltip { + display: inherit; + opacity: 1; +} + +.oppia-features .oppia-feature-top-margin { + margin-top: 10px; +} + +.oppia-features .oppia-feature-bottom-margin { + margin-bottom: 15px; +} + +.oppia-features .oppia-feature-name { + border-bottom: 1px solid #dee2e6; + overflow-wrap: break-word; +} + +.oppia-features .oppia-description-content { + overflow-wrap: anywhere; +} + +.oppia-features .oppia-condition-container { + align-items: center; + display: flex; + justify-content: space-around; +} + +.oppia-features .oppia-filter-container { + border: 1px solid #dee2e6; + padding-bottom: 10px; + padding-top: 10px; +} + +.oppia-features .oppia-rule-container { + border: 1px solid #a8a8a8; + padding-bottom: 10px; + padding-top: 10px; +} + +.oppia-features.oppia-empty-indicator { + max-width: 1050px; +} + +.oppia-features .oppia-condition-input { + max-width: 100px; +} + +.oppia-features .oppia-rule-moving-buttons-container { + display: flex; + flex-direction: column; + justify-content: space-around; + min-height: 100px; + padding: 1px +} + +.oppia-features .admin-note { + display: flex; + font-weight: bold; + justify-content: center; + width: 100%; +} + +.e2e-test-angular-dummy-feature-indicator { + bottom: 10px; +} + +@media screen and (max-width: 767px) { + .oppia-feature-column-mobile { + flex: none; + max-width: none; + } +} diff --git a/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.html b/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.html index 061c9dc317ffe..d8a0cebc076b1 100644 --- a/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.html +++ b/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.html @@ -124,165 +124,3 @@

    {{featureFlagViewModel.name Angular: Dummy Feature Enabled , Dummy Handler Enabled. - - - diff --git a/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.spec.ts b/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.spec.ts index d5ccdcfdc388c..50b5341bff4e1 100644 --- a/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.spec.ts +++ b/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the feature tab in release coordinator page. */ +// @ts-nocheck + import {NO_ERRORS_SCHEMA, ElementRef, QueryList} from '@angular/core'; import {HttpClientTestingModule} from '@angular/common/http/testing'; import { diff --git a/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.ts b/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.ts index d11204d2b1d33..eb79df29d6481 100644 --- a/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.ts +++ b/core/templates/pages/release-coordinator-page/features-tab/features-tab.component.ts @@ -39,6 +39,7 @@ import {PlatformFeatureService} from 'services/platform-feature.service'; import {FeatureFlag} from 'domain/feature-flag/feature-flag.model'; import {HttpErrorResponse} from '@angular/common/http'; import {UserGroup} from 'domain/release_coordinator/user-group.model'; +import './features-tab.component.css'; interface IntSchema { type: 'int'; @@ -53,6 +54,7 @@ interface FeatureFlagViewModel extends FeatureFlag { @Component({ selector: 'features-tab', templateUrl: './features-tab.component.html', + styleUrls: ['./features-tab.component.css'], }) export class FeaturesTabComponent implements OnInit { @ViewChildren('userGroupInput') userGroupInputs!: QueryList; diff --git a/core/templates/pages/release-coordinator-page/navbar/release-coordinator-navbar.component.css b/core/templates/pages/release-coordinator-page/navbar/release-coordinator-navbar.component.css new file mode 100644 index 0000000000000..a1af0084ba5f7 --- /dev/null +++ b/core/templates/pages/release-coordinator-page/navbar/release-coordinator-navbar.component.css @@ -0,0 +1,59 @@ +.release-coordinator-navbar .oppia-navbar-profile-release-coordinator .open > a { + background-color: #fff; + color: #00376d; +} + +.release-coordinator-navbar .nav > li > a:focus, +.release-coordinator-navbar .oppia-release-coordinator-page-dropdown-icon { + background-color: #00376d; + color: #fff; +} + +.release-coordinator-navbar .oppia-navbar-profile-release-coordinator > li > a:hover, +.release-coordinator-navbar .oppia-navbar-profile-release-coordinator > li > a:focus, +.release-coordinator-navbar .oppia-navbar-tabs-release-coordinator > li > a:hover, +.release-coordinator-navbar .oppia-navbar-tabs-release-coordinator > li > a.active, +.release-coordinator-navbar .oppia-release-coordinator-page-dropdown-icon { + background-color: #fff; + color: #00376d; +} + +.release-coordinator-navbar .oppia-navbar-profile-release-coordinator .open > a:hover, +.release-coordinator-navbar .oppia-navbar-profile-release-coordinator .open > a:focus { + background-color: #fff; + color: #00376d; +} +.oppia-navbar-tabs-release-coordinator .dropdown-toggle::after { + color: #fff; +} +.oppia-navbar-tabs-release-coordinator:hover .dropdown-toggle::after, +.oppia-navbar-tabs-release-coordinator:focus .dropdown-toggle::after { + color: #00376d; +} +.release-coordinator-navbar .oppia-navbar-release-coordinator { + top: 0; +} +.release-coordinator-navbar .navbar-container.oppia-navbar-container { + background-color: #00376d; +} +.release-coordinator-navbar .nav .dropdown:hover > .dropdown-menu > li > a.oppia-dropdown-item-text { + color: #00376d; +} +.release-coordinator-navbar .oppia-dropdown-toggle-icon { + margin-top: -1px; +} +@media screen and (max-width: 710px) { + .release-coordinator-navbar .oppia-release-coordinator-page-dropdown { + display: block; + } + .release-coordinator-navbar .oppia-hide-in-small-width-screens { + display: none; + } +} +@media screen and (min-width: 711px) { + .release-coordinator-navbar .oppia-release-coordinator-page-dropdown, + .release-coordinator-navbar .oppia-release-coordinator-page-dropdown-icon, + .release-coordinator-navbar .oppia-release-coordinator-page-dropdown-menu { + display: none; + } +} diff --git a/core/templates/pages/release-coordinator-page/navbar/release-coordinator-navbar.component.html b/core/templates/pages/release-coordinator-page/navbar/release-coordinator-navbar.component.html index c23e81c19acaa..1dd48583fc858 100644 --- a/core/templates/pages/release-coordinator-page/navbar/release-coordinator-navbar.component.html +++ b/core/templates/pages/release-coordinator-page/navbar/release-coordinator-navbar.component.html @@ -1,56 +1,3 @@ -
    - diff --git a/core/templates/pages/release-coordinator-page/release-coordinator-page.component.spec.ts b/core/templates/pages/release-coordinator-page/release-coordinator-page.component.spec.ts index dbeab5cc5609e..0b11912520541 100644 --- a/core/templates/pages/release-coordinator-page/release-coordinator-page.component.spec.ts +++ b/core/templates/pages/release-coordinator-page/release-coordinator-page.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for release coordinator page component. */ +// @ts-nocheck + import { TestBed, waitForAsync, diff --git a/core/templates/pages/release-coordinator-page/release-coordinator-page.component.ts b/core/templates/pages/release-coordinator-page/release-coordinator-page.component.ts index 8a073aa4d4384..049151592fc2a 100644 --- a/core/templates/pages/release-coordinator-page/release-coordinator-page.component.ts +++ b/core/templates/pages/release-coordinator-page/release-coordinator-page.component.ts @@ -40,6 +40,7 @@ import {WindowRef} from 'services/contextual/window-ref.service'; import {DeleteUserGroupConfirmModalComponent} from 'pages/release-coordinator-page/modals/delete-user-group-confirm-modal.component'; import {LoaderService} from 'services/loader.service'; import {UserGroup} from 'domain/release_coordinator/user-group.model'; +import './release-coordinator-page.component.css'; interface MemoryCacheProfile { totalAllocatedInBytes: string; @@ -50,6 +51,7 @@ interface MemoryCacheProfile { @Component({ selector: 'oppia-release-coordinator-page', templateUrl: './release-coordinator-page.component.html', + styleUrls: ['./release-coordinator-page.component.css'], }) export class ReleaseCoordinatorPageComponent implements OnInit { @ViewChild('userInputToAddUserToGroup') diff --git a/core/templates/pages/review-test-page/review-test-auth.guard.spec.ts b/core/templates/pages/review-test-page/review-test-auth.guard.spec.ts index 184066feacb10..03b9353211db8 100644 --- a/core/templates/pages/review-test-page/review-test-auth.guard.spec.ts +++ b/core/templates/pages/review-test-page/review-test-auth.guard.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for ReviewTestAuthGuard. */ +// @ts-nocheck + import {Location} from '@angular/common'; import {TestBed, fakeAsync, tick} from '@angular/core/testing'; import { diff --git a/core/templates/pages/review-test-page/review-test-page.component.css b/core/templates/pages/review-test-page/review-test-page.component.css new file mode 100644 index 0000000000000..16b598021cd1a --- /dev/null +++ b/core/templates/pages/review-test-page/review-test-page.component.css @@ -0,0 +1,21 @@ +.oppia-review-test-container .oppia-review-test { + color: #01645c; + font-family: 'Capriola', 'Roboto', Arial, sans-serif; + font-size: 3em; + margin-bottom: 25px; + margin-top: 0; + padding-top: 66.5px; + text-align: center; +} +.oppia-review-test-container { + color: black; + font-family: 'Roboto', Arial, sans-serif; + margin: 0 auto; + text-decoration: none; +} + +@media(max-width: 925px) { + .oppia-review-test-container { + max-width: 95%; + } +} diff --git a/core/templates/pages/review-test-page/review-test-page.component.html b/core/templates/pages/review-test-page/review-test-page.component.html index fb195b58bd490..2385a49dbf694 100644 --- a/core/templates/pages/review-test-page/review-test-page.component.html +++ b/core/templates/pages/review-test-page/review-test-page.component.html @@ -7,26 +7,3 @@ [questionPlayerConfig]="questionPlayerConfig"> - diff --git a/core/templates/pages/review-test-page/review-test-page.component.ts b/core/templates/pages/review-test-page/review-test-page.component.ts index afb021128c6e2..dbb0e5618eb6c 100644 --- a/core/templates/pages/review-test-page/review-test-page.component.ts +++ b/core/templates/pages/review-test-page/review-test-page.component.ts @@ -28,10 +28,12 @@ import {UrlInterpolationService} from 'domain/utilities/url-interpolation.servic import {UrlService} from 'services/contextual/url.service'; import {ReviewTestEngineService} from './review-test-engine.service'; import {QuestionPlayerConfig} from 'pages/exploration-player-page/current-lesson-player/learner-experience/ratings-and-recommendations.component'; +import './review-test-page.component.css'; @Component({ selector: 'review-test-page', templateUrl: './review-test-page.component.html', + styleUrls: ['./review-test-page.component.css'], }) export class ReviewTestPageComponent implements OnInit, OnDestroy { directiveSubscriptions = new Subscription(); diff --git a/core/templates/pages/signup-page/modals/license-explanation-modal.component.css b/core/templates/pages/signup-page/modals/license-explanation-modal.component.css new file mode 100644 index 0000000000000..86143728352f3 --- /dev/null +++ b/core/templates/pages/signup-page/modals/license-explanation-modal.component.css @@ -0,0 +1,3 @@ +.oppia-signup-objective-text { + line-height: 1.5; +} diff --git a/core/templates/pages/signup-page/modals/license-explanation-modal.component.html b/core/templates/pages/signup-page/modals/license-explanation-modal.component.html index 7701d23579be5..44d2c6eb01627 100644 --- a/core/templates/pages/signup-page/modals/license-explanation-modal.component.html +++ b/core/templates/pages/signup-page/modals/license-explanation-modal.component.html @@ -19,9 +19,3 @@

    translate="I18N_SIGNUP_CLOSE_BUTTON"> - - diff --git a/core/templates/pages/signup-page/modals/license-explanation-modal.component.ts b/core/templates/pages/signup-page/modals/license-explanation-modal.component.ts index 847e4f8dbe03a..9060ee15cc53a 100644 --- a/core/templates/pages/signup-page/modals/license-explanation-modal.component.ts +++ b/core/templates/pages/signup-page/modals/license-explanation-modal.component.ts @@ -20,10 +20,12 @@ import {Component} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {AppConstants} from 'app.constants'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './license-explanation-modal.component.css'; @Component({ selector: 'oppia-license-explanation-modal', templateUrl: './license-explanation-modal.component.html', + styleUrls: ['./license-explanation-modal.component.css'], }) export class LicenseExplanationModalComponent extends ConfirmOrCancelModal { SITE_NAME = AppConstants.SITE_NAME; diff --git a/core/templates/pages/signup-page/signup-page.component.css b/core/templates/pages/signup-page/signup-page.component.css new file mode 100644 index 0000000000000..68c7688ca1eda --- /dev/null +++ b/core/templates/pages/signup-page/signup-page.component.css @@ -0,0 +1,31 @@ +.radio label { + display: flex; + gap: 4px; +} + +.oppia-signup-page-container .oppia-long-text > label { + font-weight: normal; +} +.oppia-username-input { + width: 260px; +} +.oppia-username-explanation-text { + font-size: 0.8em; +} +.oppia-warning-text-container { + height: 18px; + width: 550px; +} +.oppia-warning-text { + color: #f00; + font-size: 0.8em; +} +.oppia-form-text-size { + font-size: smaller +} +.oppia-signup-site-description-text { + font-size: 1em; +} +.oppia-checkbox-margin { + margin-right: 6px; +} diff --git a/core/templates/pages/signup-page/signup-page.component.html b/core/templates/pages/signup-page/signup-page.component.html index 0beb4cd809172..594b057af7b57 100644 --- a/core/templates/pages/signup-page/signup-page.component.html +++ b/core/templates/pages/signup-page/signup-page.component.html @@ -139,37 +139,3 @@ - diff --git a/core/templates/pages/signup-page/signup-page.component.ts b/core/templates/pages/signup-page/signup-page.component.ts index 89b2aeedd6868..b2986d528c0b8 100644 --- a/core/templates/pages/signup-page/signup-page.component.ts +++ b/core/templates/pages/signup-page/signup-page.component.ts @@ -30,10 +30,12 @@ import {LicenseExplanationModalComponent} from './modals/license-explanation-mod import {RegistrationSessionExpiredModalComponent} from './modals/registration-session-expired-modal.component'; import {SignupPageBackendApiService} from './services/signup-page-backend-api.service'; import analyticsConstants from 'analytics-constants'; +import './signup-page.component.css'; @Component({ selector: 'oppia-signup-page', templateUrl: './signup-page.component.html', + styleUrls: ['./signup-page.component.css'], }) export class SignupPageComponent { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.css b/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.css new file mode 100644 index 0000000000000..2020ff6e32f84 --- /dev/null +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.css @@ -0,0 +1,86 @@ +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} +.cdk-drag-placeholder { + opacity: 0; +} +.cdk-drag-preview { + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); + box-sizing: border-box; +} +.oppia-option-list.cdk-drop-list-dragging { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} +.oppia-concept-card-preview-btn { + width: 100%; +} +.concept-card-subtitle { + color: #595959; + font-size: 13px; + font-style: italic; + padding-bottom: 12px; +} +.heading-text { + font-size: 16px; +} +.review-material-text { + font-size: 16px; +} +.btn-show-preview { + border-top: 1px solid #c1c1c1; + color: #00645d; + font-size: 14px; + padding: 10px 0; +} +.last-element { + border-bottom: 1px solid #ccc; +} +.skill-card-header-text { + font-size: 19px; + margin-bottom: 15px; + margin-left: 20px; + margin-top: 30px; +} +.skill-card-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} +.skill-card-header i { + display: none; +} +.oppia-skill-concept-card-preview-list { + display: block; +} +.oppia-skill-concept-card-preview { + list-style: none; + margin-bottom: 0; + padding-left: 0; + padding-right: 0; + position: relative; + vertical-align: top; + width: 100%; +} +.oppia-skill-concept-card-preview > li { + display: block; + position: relative; +} +@media screen and (max-width: 768px) { + .skill-card-header i { + display: block; + } + .skill-card-header-text { + margin: 0; + } + .review-material-container { + margin-top: 30px; + padding: 0; + } + .mat-card { + box-shadow: none; + padding: 0; + } +} diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.html b/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.html index b181d3b59f3ba..ee8b0f397b253 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.html +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.html @@ -30,92 +30,3 @@

    Details

    Preview Concept Card - - diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.ts b/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.ts index a62708753c036..ffa3688b05763 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.ts +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.ts @@ -28,6 +28,7 @@ import {GenerateContentIdService} from 'services/generate-content-id.service'; import {FormatRtePreviewPipe} from 'filters/format-rte-preview.pipe'; import {SkillPreviewModalComponent} from '../skill-preview-modal.component'; import {Skill} from 'domain/skill/skill.model.ts'; +import './skill-concept-card-editor.component.css'; interface BindableFieldDict { displayedConceptCardExplanation: string; @@ -36,6 +37,7 @@ interface BindableFieldDict { @Component({ selector: 'oppia-skill-concept-card-editor', templateUrl: './skill-concept-card-editor.component.html', + styleUrls: ['./skill-concept-card-editor.component.css'], }) export class SkillConceptCardEditorComponent implements OnInit { @Output() getConceptCardChange: EventEmitter = new EventEmitter(); diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.css b/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.css new file mode 100644 index 0000000000000..2b6e830cc4cb8 --- /dev/null +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.css @@ -0,0 +1,15 @@ +span.skill-description-error-message { + color: #f00; + display: block; + font-size: 0.8em; +} + +.skill-editor-spacing { + margin-bottom: 1em; +} + +@media screen and (max-width: 768px) { + skill-description-editor .skill-description-container { + padding: 0; + } +} diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.html b/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.html index 4b5589b07b47e..b0587cf802ad6 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.html +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.html @@ -24,20 +24,3 @@
    {{ tmpSkillDescription }}
    - diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.ts b/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.ts index 7fc4d699b81c6..f51f0a9490a97 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.ts +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-description-editor/skill-description-editor.component.ts @@ -30,10 +30,12 @@ import {Skill} from 'domain/skill/skill.model'; import {AppConstants} from 'app.constants'; import {SkillRights} from 'domain/skill/skill-rights.model'; import {ValidatorsService} from 'services/validators.service'; +import './skill-description-editor.component.css'; @Component({ selector: 'oppia-skill-description-editor', templateUrl: './skill-description-editor.component.html', + styleUrls: ['./skill-description-editor.component.css'], }) export class SkillDescriptionEditorComponent implements OnInit, OnDestroy { @Output() onSaveDescription = new EventEmitter(); diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.css b/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.css new file mode 100644 index 0000000000000..19815eac31c28 --- /dev/null +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.css @@ -0,0 +1,161 @@ +.skill-editor .skill-editor-content { + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +.skill-editor .skill-editor-components-container { + margin-right: 2%; + width: 40%; +} + +.skill-editor .skill-editor-info-container { + margin-top: 4%; + width: 31%; +} + +.skill-editor .status-heading { + font-size: 22px +} + +.skill-editor .status-key { + font-size: 18px +} + +.skill-editor .status-box { + margin: 12px 0; +} + +.skill-editor .status-value { + font-size: 18px; +} + +.skill-editor .topic-dashboard-link { + font-size: 15px; + margin-bottom: 5%; + margin-left: -24%; + margin-top: 3%; +} + +.skill-editor .topic-dashboard-link a { + color: #015f9c; + text-decoration: none; +} + +.skill-editor .skill-details-container { + align-items: center; + border-radius: 4px; + display: flex; + flex-wrap: wrap; + justify-content: space-evenly; + text-align: center; + width: 100%; +} + +.skill-editor .misconception-count, +.skill-editor .create-question { + border-right: 2px solid #707070; + display: inline-block; + width: 33%; +} + +.skill-editor .count-value { + font-size: 24px; + font-weight: bold; +} + +.skill-editor .count-text { + font-size: 15px; +} + +.skill-editor .create-question { + border-right: none; + cursor: pointer; +} + +.skill-editor .create-question i { + color: #0b6c63; + font-size: 30px; +} + +.skill-editor .create-text { + color: #0b6c63; +} +.skill-editor .skill-editor-status { + margin: 30px auto 0; + width: 97%; +} +.skill-editor .skill-editor-helper-box { + align-items: center; + background-color: #fff; + border: 1px solid #aaaac1; + border-radius: 6px; + box-shadow: none; + height: 110px; + justify-content: center; + margin: 30px auto 0; + width: 97%; +} +.skill-editor .skill-editor-add-btn { + font-weight: bold; + margin: 10px 0; +} + +@media screen and (max-width: 1250px) { + .skill-editor .skill-editor-components-container { + width: 50%; + } +} + +@media screen and (max-width: 1000px) { + .skill-editor .skill-editor-components-container { + margin-right: 0; + width: 55%; + } + + .skill-editor .skill-editor-info-container { + width: 45%; + } + + .skill-editor .misconception-count, + .skill-editor .create-question { + height: 80px; + } +} + +@media screen and (max-width: 800px) { + .skill-editor .skill-editor-components-container { + width: 90%; + } + + .skill-editor .skill-editor-info-container { + margin-bottom: 15px; + margin-top: -18px; + width: 92%; + } + + .skill-editor .misconception-count, + .skill-editor .create-question { + height: auto; + } +} + +@media screen and (max-width: 500px) { + .skill-editor .skill-editor-components-container { + width: 100%; + } + + .skill-editor .skill-editor-info-container { + width: 100%; + } + + skill-editor-main-tab .skill-editor { + margin-top: 20px; + } + + .skill-editor .misconception-count, + .skill-editor .create-question { + height: 80px; + width: 33%; + } +} diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.html b/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.html index 7ae1d1959b32b..ababe5632695d 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.html +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.html @@ -65,166 +65,3 @@ - diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.ts b/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.ts index 6510fb2eb5895..e9ba539c5fe8b 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.ts +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.ts @@ -30,6 +30,7 @@ import {Topic} from 'domain/topic/topic-object.model'; import {PageTitleService} from 'services/page-title.service'; import {FocusManagerService} from 'services/stateful/focus-manager.service'; import {SkillEditorRoutingService} from '../services/skill-editor-routing.service'; +import './skill-editor-main-tab.component.css'; import { AssignedSkillTopicData, SkillEditorStateService, @@ -38,6 +39,7 @@ import { @Component({ selector: 'oppia-skill-editor-main-tab', templateUrl: './skill-editor-main-tab.component.html', + styleUrls: ['./skill-editor-main-tab.component.css'], }) export class SkillEditorMainTabComponent implements OnInit, AfterContentChecked diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.css b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.css new file mode 100644 index 0000000000000..08af5f7f707ee --- /dev/null +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.css @@ -0,0 +1,3 @@ +misconception-editor .oppia-misconception-editor-section { + padding-bottom: 10px; +} diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.html b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.html index af4eb3d49d309..bc56e24eda71e 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.html +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.html @@ -1,8 +1,3 @@ -
    diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.ts b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.ts index 3a479a3ea924a..f80327c6f776a 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.ts +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/misconception-editor.component.ts @@ -30,6 +30,7 @@ import {SkillUpdateService} from 'domain/skill/skill-update.service'; import {SkillEditorStateService} from 'pages/skill-editor-page/services/skill-editor-state.service'; import {Skill} from 'domain/skill/skill.model.ts'; import {Misconception} from 'domain/skill/misconception.model'; +import './misconception-editor.component.css'; interface MisconceptionFormSchema { type: 'html'; @@ -46,6 +47,7 @@ interface Container { @Component({ selector: 'oppia-misconception-editor', templateUrl: './misconception-editor.component.html', + styleUrls: ['./misconception-editor.component.css'], }) export class MisconceptionEditorComponent implements OnInit { @Output() onMisconceptionChange = new EventEmitter(); diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.css b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.css new file mode 100644 index 0000000000000..63e0c295ac858 --- /dev/null +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.css @@ -0,0 +1,65 @@ +.last-element { + border-bottom: 1px solid #ccc; +} + +.add-skill-misconception-button { + background-color: #058ca6; + color: #fff; +} + +.helper-text { + color: #595959; +} + +.helper-text span { + font-size: 13px; +} + +.heading-text { + font-size: 16px; +} + +.oppia-misconception-card-header { + display: flex; + justify-content: space-between; +} + +.oppia-misconception-card-header i { + display: none; +} + +.oppia-option-list-container { + padding-bottom: 10px; +} + +.oppia-misconception-list-item { + margin-top: 0; +} + +.oppia-skill-misconception-card-preview-list { + display: block; +} + +.oppia-skill-misconception-card-preview { + list-style: none; + margin-bottom: 0; + padding-left: 0; + padding-right: 0; + position: relative; + vertical-align: top; + width: 100%; +} + +.oppia-skill-misconception-card-preview > li { + display: block; + position: relative; +} + +@media screen and (max-width: 768px) { + .oppia-misconception-card-header i { + display: block; + } + .skill-misconception-content { + padding: 0; + } +} diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.html b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.html index b6ac910a647c0..f7735c74a0ba2 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.html +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.html @@ -59,70 +59,3 @@
    - diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.ts b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.ts index 35c921801e06c..f6da7c518e494 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.ts +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.ts @@ -26,10 +26,12 @@ import {SkillUpdateService} from 'domain/skill/skill-update.service'; import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; import {Misconception} from 'domain/skill/misconception.model'; import {Skill} from 'domain/skill/skill.model.ts'; +import './skill-misconceptions-editor.component.css'; @Component({ selector: 'oppia-skill-misconceptions-editor', templateUrl: './skill-misconceptions-editor.component.html', + styleUrls: ['./skill-misconceptions-editor.component.css'], }) export class SkillMisconceptionsEditorComponent implements OnInit { @Output() getMisconceptionsChange = new EventEmitter(); diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.css b/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.css new file mode 100644 index 0000000000000..7251689f6d661 --- /dev/null +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.css @@ -0,0 +1,68 @@ +.mat-card { + border: 1px solid #74737375; +} + +.remove-skill-icon { + cursor: pointer; + float: right; + opacity: 0.5; + padding-right: 10px; +} + +.remove-skill-icon:hover { + opacity: 1; +} + +.skill-prerequisite-link { + color: #000; + opacity: 0.85; + text-decoration: none; +} + +.skill-prerequisite-link:hover { + opacity: 1; +} + +.skill-editor-add-btn { + background-color: #058ca6; + color: #fff; + margin-top: 1vh; +} + +.skill-id { + background-color: #efefef; + margin-left: 0; +} +.oppia-add-prerequisite-skill-button { + background-color: #058ca6; + color: #fff; +} +.heading-text { + font-size: 16px; +} +.skill-id:hover { + background-color: #e0e0e0; +} +.skill-description-card { + max-height: 10em; + overflow-wrap: break-word; + padding-bottom: 10px; + padding-left: 10px; + padding-top: 10px; +} +.oppia-prerequisite-skills-header { + display: flex; + justify-content: space-between; +} +.oppia-prerequisite-skills-header i { + display: none; +} + +@media screen and (max-width: 768px) { + .oppia-prerequisite-skills-header i { + display: block; + } + .prerequisite-skill-content { + padding: 0; + } +} diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.html b/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.html index f9f218a0e5f03..54551fbeb3a86 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.html +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.html @@ -28,73 +28,3 @@ - diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.spec.ts b/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.spec.ts index ae8c0a9cdea5b..6cdbf44ec3dfe 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.spec.ts +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the skill prerequisite skills editor. */ +// @ts-nocheck + import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; import { diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.ts b/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.ts index 565ff283cbbf0..60f239c183c30 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.ts +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.ts @@ -34,10 +34,12 @@ import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {Component, OnInit} from '@angular/core'; import {Subscription} from 'rxjs'; import {SkillBackendApiService} from 'domain/skill/skill-backend-api.service'; +import './skill-prerequisite-skills-editor.component.css'; @Component({ selector: 'oppia-skill-prerequisite-skills-editor', templateUrl: './skill-prerequisite-skills-editor.component.html', + styleUrls: ['./skill-prerequisite-skills-editor.component.css'], }) export class SkillPrerequisiteSkillsEditorComponent implements OnInit { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.css b/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.css new file mode 100644 index 0000000000000..08056ebe23892 --- /dev/null +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.css @@ -0,0 +1,10 @@ +.skill-preview-modal { + background-color: #f0ffff; + padding: 30px 35px; +} +.skill-preview-modal .skill-preview-modal-title { + font-size: 28px; +} +.skill-preview-modal .skill-preview-modal-content { + margin-top: 15px; +} diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.html b/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.html index 78173f6b34cc0..e41ef6619c5b4 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.html +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.html @@ -4,16 +4,3 @@

    {{ skillDescription }}

    - - diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.ts b/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.ts index 9bd97d9474d87..45129b44bb259 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.ts +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.ts @@ -19,10 +19,12 @@ import {Component, Input} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import './skill-preview-modal.component.css'; @Component({ selector: 'skill-preview-modal', templateUrl: './skill-preview-modal.component.html', + styleUrls: ['./skill-preview-modal.component.css'], }) export class SkillPreviewModalComponent extends ConfirmOrCancelModal { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.css b/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.css new file mode 100644 index 0000000000000..9be62cff4c642 --- /dev/null +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.css @@ -0,0 +1,22 @@ +.edit-rubrics-header strong { + font-size: 16px; +} +.oppia-skill-rubrics-header { + display: flex; + justify-content: space-between; +} +.oppia-skill-rubrics-header i { + display: none; +} +.oppia-rubrics-editor-card { + margin-bottom: 5vh; +} + +@media screen and (max-width: 768px) { + .oppia-skill-rubrics-header i { + display: block; + } + .edit-rubrics-header { + padding: 0; + } +} diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.html b/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.html index b0a9e3ff31890..0742f3483ae10 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.html +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.html @@ -21,28 +21,3 @@ - - diff --git a/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.ts b/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.ts index 5b83b0ee2e163..bc2a265ceffe3 100644 --- a/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.ts +++ b/core/templates/pages/skill-editor-page/editor-tab/skill-rubrics-editor/skill-rubrics-editor.component.ts @@ -23,10 +23,12 @@ import {SkillUpdateService} from 'domain/skill/skill-update.service'; import {Skill} from 'domain/skill/skill.model.ts'; import {SkillEditorStateService} from 'pages/skill-editor-page/services/skill-editor-state.service'; import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; +import './skill-rubrics-editor.component.css'; @Component({ selector: 'oppia-skill-rubrics-editor', templateUrl: './skill-rubrics-editor.component.html', + styleUrls: ['./skill-rubrics-editor.component.css'], }) export class SkillRubricsEditorComponent implements OnInit, OnDestroy { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/skill-editor-page/modal-templates/add-misconception-modal.component.css b/core/templates/pages/skill-editor-page/modal-templates/add-misconception-modal.component.css new file mode 100644 index 0000000000000..be3b7e860a830 --- /dev/null +++ b/core/templates/pages/skill-editor-page/modal-templates/add-misconception-modal.component.css @@ -0,0 +1,8 @@ +.add-misconception-modal .oppia-editor-description { + font-size: .8em; + font-style: italic; +} + +.add-misconception-modal .oppia-misconception-editor-section { + padding-bottom: 10px; +} diff --git a/core/templates/pages/skill-editor-page/modal-templates/add-misconception-modal.component.html b/core/templates/pages/skill-editor-page/modal-templates/add-misconception-modal.component.html index 8c3fe1110eda4..bbc99da5f15dc 100644 --- a/core/templates/pages/skill-editor-page/modal-templates/add-misconception-modal.component.html +++ b/core/templates/pages/skill-editor-page/modal-templates/add-misconception-modal.component.html @@ -1,13 +1,3 @@ -
    - - - - diff --git a/core/templates/pages/skill-editor-page/navbar/skill-editor-navbar.component.spec.ts b/core/templates/pages/skill-editor-page/navbar/skill-editor-navbar.component.spec.ts index 4a779c9be42bc..c5f8fc0a3725a 100644 --- a/core/templates/pages/skill-editor-page/navbar/skill-editor-navbar.component.spec.ts +++ b/core/templates/pages/skill-editor-page/navbar/skill-editor-navbar.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the Skill Editor Navbar Component. */ +// @ts-nocheck + import { ComponentFixture, fakeAsync, diff --git a/core/templates/pages/skill-editor-page/navbar/skill-editor-navbar.component.ts b/core/templates/pages/skill-editor-page/navbar/skill-editor-navbar.component.ts index c4315ec95c616..af13b65943b3a 100644 --- a/core/templates/pages/skill-editor-page/navbar/skill-editor-navbar.component.ts +++ b/core/templates/pages/skill-editor-page/navbar/skill-editor-navbar.component.ts @@ -31,10 +31,12 @@ import {SkillUpdateService} from 'domain/skill/skill-update.service'; import {ConfirmQuestionExitModalComponent} from 'components/question-directives/modal-templates/confirm-question-exit-modal.component'; import {QuestionUndoRedoService} from 'domain/editor/undo_redo/question-undo-redo.service'; import {PreventPageUnloadEventService} from 'services/prevent-page-unload-event.service'; +import './skill-editor-navbar.component.css'; @Component({ selector: 'oppia-skill-editor-navbar', templateUrl: './skill-editor-navbar.component.html', + styleUrls: ['./skill-editor-navbar.component.css'], }) export class SkillEditorNavabarComponent implements OnInit { // These properties below are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.css b/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.css new file mode 100644 index 0000000000000..d3d2c79a653dd --- /dev/null +++ b/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.css @@ -0,0 +1,7 @@ +.oppia-page-card { + margin: 0; +} + +.questions { + padding-bottom: 10px; +} diff --git a/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.html b/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.html index dda506efdc8ef..6e80be1ce5806 100644 --- a/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.html +++ b/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.html @@ -11,13 +11,3 @@ > - - diff --git a/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.spec.ts b/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.spec.ts index 89b289eccf47b..6fa4d4b6e157e 100644 --- a/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.spec.ts +++ b/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the Skill question tab Component. */ +// @ts-nocheck + import { ComponentFixture, TestBed, diff --git a/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.ts b/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.ts index 560cd459b4974..ac79e5417d4a7 100644 --- a/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.ts +++ b/core/templates/pages/skill-editor-page/questions-tab/skill-questions-tab.component.ts @@ -21,6 +21,7 @@ import {Rubric} from 'domain/skill/rubric.model'; import {Skill} from 'domain/skill/skill.model.ts'; import {SkillSummary} from 'domain/skill/skill-summary.model'; import {Subscription} from 'rxjs'; +import './skill-questions-tab.component.css'; import { CategorizedSkills, TopicsAndSkillsDashboardBackendApiService, @@ -33,6 +34,7 @@ import { @Component({ selector: 'oppia-questions-tab', templateUrl: './skill-questions-tab.component.html', + styleUrls: ['./skill-questions-tab.component.css'], }) export class SkillQuestionsTabComponent implements OnInit, OnDestroy { // These properties below are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/skill-editor-page/services/skill-editor-state.service.spec.ts b/core/templates/pages/skill-editor-page/services/skill-editor-state.service.spec.ts index a881d3313770c..3bb7701c6a525 100644 --- a/core/templates/pages/skill-editor-page/services/skill-editor-state.service.spec.ts +++ b/core/templates/pages/skill-editor-page/services/skill-editor-state.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for SkillEditorStateService.js */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {fakeAsync, TestBed, tick} from '@angular/core/testing'; diff --git a/core/templates/pages/skill-editor-page/skill-editor-page.component.css b/core/templates/pages/skill-editor-page/skill-editor-page.component.css new file mode 100644 index 0000000000000..9a6da3c61038f --- /dev/null +++ b/core/templates/pages/skill-editor-page/skill-editor-page.component.css @@ -0,0 +1,81 @@ +.skill-editor-main .navbar-helper { + background-color: #00609c; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 2px 4px rgba(0, 0, 0, 0.23); + display: flex; + height: 70px; + margin-bottom: 20px; + text-align: center; + width: 100%; + z-index: 10; +} + +.skill-editor-main .navbar-text { + align-items: center; + color: #fff; + display: flex; + font-family: Capriola, Roboto, Arial, sans-serif; + font-size: 20px; + margin-left: 4%; + width: 35%; +} + +.skill-editor-main .oppia-editor-warnings-box { + cursor: default; + max-width: none; +} + +.skill-editor-main ul { + display: flex; + flex-wrap: wrap; + height: 100%; + justify-content: center; + text-align: center; +} + +.skill-editor-main .navbar-tab { + align-items: center; + display: flex; +} + +.skill-editor-main .navbar-tab-icon { + color: #fff; + display: block; + font-size: 22px; + width: 100%; +} + +.skill-editor-main .navbar-tab span { + color: #333; + font-size: 12px; +} +.skill-editor-main .navbar-tab-active { + background-color: #e6e6e6; + box-shadow: 0 3px 2px 0 #e6e6e6; +} +.skill-editor-main .nav-list-item { + align-items: center; + cursor: pointer; + display: flex; + justify-content: center; + width: 90px; +} +.skill-editor-main .navbar-icons .nav-list-item:hover { + background-color: #4eb0c0; +} +.skill-editor-warning-box { + display: block; + width: 250px; +} +.skill-editor-main .navbar-tab-active-icon { + color: #00609c; +} +@media screen and (max-width: 768px) { + .skill-editor-main .navbar-helper { + display: none; + } + + .skill-editor-main .oppia-editor-warnings-box { + display: block; + width: 200px; + } +} diff --git a/core/templates/pages/skill-editor-page/skill-editor-page.component.html b/core/templates/pages/skill-editor-page/skill-editor-page.component.html index b950685a58c3c..93fb49fd78492 100644 --- a/core/templates/pages/skill-editor-page/skill-editor-page.component.html +++ b/core/templates/pages/skill-editor-page/skill-editor-page.component.html @@ -73,88 +73,3 @@ - - - diff --git a/core/templates/pages/skill-editor-page/skill-editor-page.component.ts b/core/templates/pages/skill-editor-page/skill-editor-page.component.ts index 1cd45a7970064..54c4ac7948b2e 100644 --- a/core/templates/pages/skill-editor-page/skill-editor-page.component.ts +++ b/core/templates/pages/skill-editor-page/skill-editor-page.component.ts @@ -36,10 +36,12 @@ import {SkillEditorStalenessDetectionService} from './services/skill-editor-stal import {SkillEditorStateService} from './services/skill-editor-state.service'; import {ConfirmQuestionExitModalComponent} from 'components/question-directives/modal-templates/confirm-question-exit-modal.component'; import {QuestionUndoRedoService} from 'domain/editor/undo_redo/question-undo-redo.service'; +import './skill-editor-page.component.css'; @Component({ selector: 'oppia-skill-editor-page', templateUrl: './skill-editor-page.component.html', + styleUrls: ['./skill-editor-page.component.css'], }) export class SkillEditorPageComponent implements OnInit { constructor( diff --git a/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.css b/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.css new file mode 100644 index 0000000000000..015affbcb2728 --- /dev/null +++ b/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.css @@ -0,0 +1,146 @@ +.content-box { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-top: 50px; +} + +.question-filter-box { + width: 25%; +} + +.seperator-block { + text-align: center; + width: 10%; +} + +.seperator { + background-color: #707070; + display: inline-block; + height: 60vh; + width: 2px; +} + +.content-heading { + border-bottom: 1px solid #707070; + width: 100%; +} + +.question-box { + background-color: #fff; + border-radius: 7px; + height: 250px; + margin-top: 20px; + overflow-y: scroll; + padding: 30px; +} + +.skill-description-box { + margin-top: 30px; +} + +.skill-description { + background-color: #fff; + border-radius: 7px; + padding: 30px; +} + +.skill-question-container { + width: 40%; +} + +.question-text { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 60%; +} + +.question-data { + align-items: center; + cursor: pointer; + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.question-filters { + align-items: center; + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.question-filters input { + width: 60%; +} + +.question-filters select { + width: 38%; +} + +.preview-box { + margin-top: -40px; + padding: 30px 0; +} + +.content-text-box { + border-bottom: 2px solid #707070; + margin-bottom: 15px; +} + +.question-list-item { + border-bottom: 1px solid #e6e6e6; + margin: 12px 0; +} + +.headings { + font-size: 30px; + font-weight: bold; +} + +.heading-secondary { + font-size: 20px; + font-weight: bold; +} + +@media screen and (max-width: 1250px) { + .question-filter-box { + width: 35%; + } + + .skill-question-container { + width: 45%; + } +} + +@media screen and (max-width: 1000px) { + .seperator-block { + width: 5%; + } +} + +@media screen and (max-width: 700px) { + .question-filter-box { + width: 90%; + } + + .seperator-block { + margin: 20px 0; + text-align: center; + width: 100%; + } + + .seperator { + background-color: #707070; + display: inline-block; + height: 2px; + width: 90%; + } + + .skill-question-container { + margin: 0; + width: 90%; + } +} diff --git a/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.html b/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.html index ed6115c53f5d3..faf21ecf756f3 100644 --- a/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.html +++ b/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.html @@ -81,153 +81,3 @@ - - - diff --git a/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.spec.ts b/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.spec.ts index ba4816013ebad..319405fbe4055 100644 --- a/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.spec.ts +++ b/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for skill preview tab component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { diff --git a/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.ts b/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.ts index 1be476343fa84..032495530f72d 100644 --- a/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.ts +++ b/core/templates/pages/skill-editor-page/skill-preview-tab/skill-preview-tab.component.ts @@ -30,10 +30,12 @@ import {ConversationFlowService} from 'pages/exploration-player-page/services/co import {UrlService} from 'services/contextual/url.service'; import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; import {SkillEditorStateService} from '../services/skill-editor-state.service'; +import './skill-preview-tab.component.css'; @Component({ selector: 'oppia-skill-preview-tab', templateUrl: './skill-preview-tab.component.html', + styleUrls: ['./skill-preview-tab.component.css'], }) export class SkillPreviewTabComponent implements OnInit, OnDestroy { constructor( diff --git a/core/templates/pages/splash-page/splash-page.component.spec.ts b/core/templates/pages/splash-page/splash-page.component.spec.ts index 604433582565c..419d783fa1d2f 100644 --- a/core/templates/pages/splash-page/splash-page.component.spec.ts +++ b/core/templates/pages/splash-page/splash-page.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the splash page. */ +// @ts-nocheck + import {EventEmitter} from '@angular/core'; import {HttpClientTestingModule} from '@angular/common/http/testing'; import {TestBed, fakeAsync, flushMicrotasks} from '@angular/core/testing'; diff --git a/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.css b/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.css new file mode 100644 index 0000000000000..b0aba4e4bea74 --- /dev/null +++ b/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.css @@ -0,0 +1,17 @@ +.story-node-breadcrumb { + margin-left: 2%; +} +.story-chapter-name { + font-size: 15px; + margin-bottom: 35px; + margin-left: 4%; +} +.story-name { + color: #666; + cursor: pointer; +} +@media screen and (max-width: 768px) { + .story-chapter-name { + display: none; + } +} diff --git a/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.html b/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.html index 3b2230a2b5262..65f5e597cede9 100644 --- a/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.html +++ b/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.html @@ -24,22 +24,3 @@ [plannedPublicationDateMsecs]="node.getPlannedPublicationDateMsecs()"> - diff --git a/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.spec.ts b/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.spec.ts index 1ae54b8b04f9c..6c98c705cd723 100644 --- a/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.spec.ts +++ b/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.spec.ts @@ -15,6 +15,7 @@ /** * @fileoverview Unit tests for chapter editor tab component. */ +// @ts-nocheck import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; diff --git a/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.ts b/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.ts index 5eb0504482a98..36f3d4632fb39 100644 --- a/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.ts +++ b/core/templates/pages/story-editor-page/chapter-editor/chapter-editor-tab.component.ts @@ -24,10 +24,12 @@ import {StoryUpdateService} from 'domain/story/story-update.service'; import {Story} from 'domain/story/story.model'; import {StoryContents} from 'domain/story/story-contents-object.model'; import {StoryNode} from 'domain/story/story-node.model'; +import './chapter-editor-tab.component.css'; @Component({ selector: 'oppia-chapter-editor-tab', templateUrl: './chapter-editor-tab.component.html', + styleUrls: ['./chapter-editor-tab.component.css'], }) export class ChapterEditorTabComponent implements OnInit, OnDestroy { // These properties are all set in initEditor(), which is called from the diff --git a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.css b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.css new file mode 100644 index 0000000000000..2e309167c780e --- /dev/null +++ b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.css @@ -0,0 +1,839 @@ +.story-length-validation-error { + align-items: center; + background-color: #f8d7da; + border: 0.0625rem solid #dc3545; + border-radius: 0.25rem; + color: #dc3545; + display: flex; + justify-content: center; + padding: 0.25rem; +} + +.oppia-page-card.chapter-control-container { + padding: 10px; +} + +.no-chapters-message { + font-family: 'Capriola', 'Roboto', Arial, sans-serif; + font-size: 18px; + padding-top: 6px; + text-align: center; +} + +.chapter-control-bar { + display: flex; + justify-content: flex-end; + margin-right: 15px; +} + +.publish-upto-chapter-menu { + background-color: #008098; + border-radius: 5px; + color: white; + display: flex; + font-weight: bold; + gap: 1rem; + justify-content: space-around; + margin: 15px 0 15px; + padding: 10px; +} + +.publish-upto-chapter-dropdown { + background-color: rgb(184 231 247); + font-size: 14px; + width: 60px; +} + +.publish-upto-chapter-dropdown option { + background-color: #fff; + border: 0.5px solid #000; +} + +.publish-upto-chapter-dropdown option:disabled { + background-color: rgb(207, 206, 206); +} + +.chapter-list-heading th { + border: 1px solid #dbdbdb; + border-top: 4px solid black; + font-size: 15px; + font-weight: bold; + height: 30px; + padding: 2%; + vertical-align: top; + word-wrap: break-word; +} + +.chapter-list-heading .drag-handler-header { + border: none; +} + +.published-node { + background-color: rgb(217, 248, 231); +} + +.ready-to-publish-node { + background-color: #f8f1e3; +} + +.draft-node { + background-color: rgb(239, 239, 239); +} + +.story-editor-node { + align-items: center; + border-bottom: 1px solid #dbdbdb; + cursor: pointer; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding: 15px 0; + position: relative; +} + +.story-editor-node--no-bottom-border { + border-bottom: none; +} + +.story-editor-node-title { + cursor: pointer; + text-align: left; + width: 95%; +} + +.story-node .drag-handler { + background-color: #fff; + border: none; + cursor: move; + padding: 1%; + width: 5%; +} + +.draggable-icon-indicator { + position: absolute; + top: 1.5vh; +} + +.story-node .chapter-index { + border: 1px solid #dbdbdb; + font-weight: bold; + padding-left: 5px; + width: 6%; +} + +.story-node .chapter-title { + border: 1px solid #dbdbdb; + font-weight: bold; + width: 25%; +} + +.story-node .chapter-status { + border: 1px solid #dbdbdb; + width: 20%; +} + +.story-node .chapter-last-modified { + border: 1px solid #dbdbdb; +} + +.story-node .chapter-planned-publication-date { + border: 1px solid #dbdbdb; + font-weight: bold; +} + +.story-node .edit-options-container { + background-color: #fff; + border: none; + padding-left: 5px; +} + +.story-node td { + height: 50px; + padding: 2%; + vertical-align: top; + word-wrap: break-word; +} + +.story-node { + cursor: pointer; + position: relative; +} + +.story-editor-node:hover { + cursor: grab; +} + +.topic-story-name { + font-size: 15px; + margin-bottom: 35px; + margin-left: 4%; +} + +.topic-name { + color: #666; + cursor: pointer; +} + +.story-editor-node:active { + cursor: move; +} + +.story-nodes-container { + background-color: #fff; + text-align: left; + white-space: initial; +} + +.story-nodes-container table { + font-size: 14px; +} + +.node-editor { + display: inline-block; + min-height: 25vh; + padding-left: 5%; + width: 65%; +} + +.story-node-editor { + margin-top: 20px; +} + +.story-node-editor-container { + margin-left: 5%; + padding: 10px 0; +} + +.story-node-editor-container span { + font-size: 25px; +} + +.form-heading { + font-size: 15px; +} + +.story-title { + margin: 20px 0 15px; +} + +.story-thumbnail { + margin: 25px 0; +} + +.story-nodes-title { + margin-bottom: 2%; +} + +.story-notes .save-button { + margin-top: 1.5vh; +} + +.story-description textarea, +.story-meta-tag-content textarea { + height: 10vh; +} + +.story-meta-tag-content { + margin-bottom: 25px; + margin-top: 32px; +} + +.parent-container { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 0 auto; +} +.content-container { + margin-bottom: 40px; + margin-right: 3%; + width: 50%; +} +.chapter-list-container { + width: 35%; +} +.content-card { + margin: 0; +} +.chapter-editor-card { + width: 80%; +} +.show-story-preview-button { + border-top: 1px solid #c1c1c1; + color: #00645d; + font-size: 15px; + padding: 10px 0; + width: 100%; +} +.story-content-card { + padding: 0; +} +.story-content { + padding: 5px 55px 30px 45px; +} +.story-notes p { + margin: 0; +} +.story-card-header { + font-size: 20px; + margin: 40px 18px 5px; +} +.story-contain-text { + font-size: smaller; +} +.add-chapter-btn { + background-color: #008098; + color: #fff; + font-weight: bold; + margin: 25px 0 10px; + text-align: left; +} +.add-chapter-button { + background-color: #008098; + color: #fff; + font-weight: bold; + margin: 15px 5% 15px; + text-align: left; +} +.chapter-list-card-header { + align-items: center; + display: flex; + flex-wrap: wrap; + font-size: 20px; + font-weight: bold; + justify-content: space-between; +} +.chapter-list-card-heading { + border-bottom: 2px solid #000; + padding-left: 4%; +} +.chapter-list-card-heading span { + font-weight: bold; +} +.chapter-option-box { + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.5); + padding: 0; + position: absolute; + right: -5px; + width: 140px; + z-index: 1; +} +.chapter-edit-option { + border: 1px solid rgba(0, 0, 0, 0.5); + cursor: pointer; + font-size: 15px; + padding: 5px; + text-align: left; +} +.chapter-edit-option span { + margin-left: 5%; +} +.chapter-edit-toggle-button { + cursor: pointer; + position: relative; +} +.chapter-list-card-header i { + display: none; +} +.story-editor-notes-edit-icon { + padding-top: 4px; +} +.cdk-drag-placeholder { + opacity: 0; +} + +.oppia-error-message-container { + background-color: #fff; + border: 1px solid #00376d; + bottom: 30px; + max-width: 30%; + padding: 5px; + position: fixed; + right: 30px; + text-align: center; + z-index: 3000; +} +@media screen and (max-width: 1150px) { + .content-container { + width: 55%; + } + .chapter-list-container { + margin-right: 2%; + width: 35%; + } + .add-chapter-button { + font-size: 12px; + } + .publish-upto-chapter-label { + font-size: 14px; + } + .chapter-list-heading th { + font-size: 14px; + } +} +@media screen and (max-width: 1000px) { + .parent-container { + width: 100%; + } + .chapter-list-container { + margin-right: 0; + width: 40%; + } +} +@media screen and (max-width: 800px) { + .story-content { + padding: 0; + } +} +@media screen and (max-width: 768px) { + .content-container, + .chapter-list-container { + margin-bottom: 100px; + margin-right: 0; + width: 100%; + } + + .content-container { + margin-bottom: 0; + } + + .topic-story-name { + display: none; + } + + .chapter-list-container { + margin-top: 40px; + } + + .draggable-icon-indicator { + top: 2.5vh; + } + + .story-notes { + width: 95%; + } + + .story-node-editor { + margin-top: 0; + } + + .story-card-header { + margin: 0; + } + + .chapter-option-box { + right: 20px; + } + + .chapter-list-card-header i { + display: block; + } + .chapter-list-card-header div { + margin-top: 16px; + width: 100%; + } + .chapter-list-card-header { + margin-bottom: 0; + padding-bottom: 18px; + } + .chapter-list-card-heading { + padding-top: 15px; + } +} +@media screen and (max-width: 425px) { + .drag-handler,.drag-handler-header { + display: none; + } + .chapter-control-panel { + padding: 20px 0 15px; + } +} +@media screen and (max-width: 375px) { + .chapter-control-bar { + flex-direction: column; + padding-left: 5px; + } + .publish-upto-chapter-menu { + margin-top: 5px; + } + .add-chapter-button { + font-size: 14px; + } + .story-nodes-container table { + font-size: 12px; + } + .chapter-edit-option span { + font-size: 12px; + } +} + +.arc-mode-container { + width: 35%; +} + +.arc-mode-card { + border-radius: 10px; + box-shadow: 0 1px 4px rgba(0,0,0,.08); + overflow: hidden; + padding: 0; +} + +.arc-mode-card-header { + align-items: center; + border-bottom: 1px solid #e9edf2; + display: flex; + justify-content: space-between; + padding: 14px 20px; +} + +.arc-mode-card-heading-text { + font-size: 20px; + font-weight: 700; +} + +.arc-mode-card-body { + padding: 0; +} + +.arc-mode-publish-bar { + align-items: center; + background: #007f8c; + border-radius: 6px; + color: #fff; + display: flex; + gap: 12px; + justify-content: flex-end; + margin: 12px 16px 12px auto; + padding: 8px 14px; + width: fit-content; +} + +.arc-mode-publish-label { + font-size: 14px; + font-weight: 600; +} + +.arc-mode-publish-select { + background: #b8e5ef; + border: none; + border-radius: 4px; + font-size: 14px; + padding: 2px 6px; + width: 72px; +} + +.arc-table-wrapper { + border: 1px solid #d7dce3; + border-radius: 8px; + overflow: visible; +} + +.arc-chapter-table { + border-collapse: collapse; + font-size: 14px; + table-layout: fixed; + width: 100%; +} + +.arc-table-heading th { + background: #fff; + border-bottom: 3px solid #1e1e1e; + border-right: 1px solid #e8edf3; + color: #363636; + font-size: 13px; + font-weight: 700; + line-height: 1.3; + padding: 10px 12px; + text-align: left; + white-space: normal; +} + +.arc-table-heading th:last-child { + border-right: none; +} + +.arc-table-heading th.col-drag { width: 32px; } +.arc-table-heading th.col-num { width: 40px; } +.arc-table-heading th.col-name { width: 20%; } +.arc-table-heading th.col-status { width: 16%; } +.arc-table-heading th.col-modified { width: 24%; } +.arc-table-heading th.col-date { width: 21%; } +.arc-table-heading th.col-actions { + border-right: none; + width: 28px; +} + +.arc-header-row td, +.arc-header-cell { + border: none !important; + padding: 0 !important; +} + +.arc-header-card { + align-items: flex-start; + border: 1px solid #b8dbbf; + border-radius: 0; + display: flex; + justify-content: space-between; + margin: 0; + padding: 14px 18px; +} + +.arc-header-left { + align-items: flex-start; + display: flex; + gap: 12px; +} + +.arc-flag-badge { + align-items: center; + border-radius: 50%; + color: #fff; + display: inline-flex; + flex-shrink: 0; + font-size: 14px; + height: 38px; + justify-content: center; + margin-top: 1px; + width: 38px; +} + +.arc-header-text { + display: flex; + flex-direction: column; + gap: 3px; +} + +.arc-header-title { + color: #1a2633; + font-size: 17px; + font-weight: 700; + line-height: 1.25; +} + +.arc-header-description { + color: #4a5568; + font-size: 14px; + line-height: 1.35; + max-width: 400px; +} + +.arc-header-chapter-count { + color: #6b7a90; + font-size: 12px; + font-weight: 600; +} + +.arc-header-actions { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 14px; +} + +.arc-action-btn { + align-items: center; + background: transparent; + border: none; + cursor: pointer; + display: inline-flex; + font-size: 14px; + font-weight: 600; + gap: 5px; + padding: 0; + white-space: nowrap; +} + +.arc-edit-btn { color: #198754; } +.arc-remove-btn { color: #dc3545; } +.arc-action-btn:hover { opacity: .75; } + +/* Split row */ +.arc-split-row td, +.arc-split-cell { + border: none !important; + padding: 0 !important; +} + +.arc-split-line-container { + align-items: center; + display: flex; + padding: 10px 8px; +} + +.arc-split-line { + border-top: 2px dashed #c9cdd4; + flex: 1; + height: 0; +} + +.arc-split-btn { + align-items: center; + background: transparent; + border: none; + color: #1c5cc9; + cursor: pointer; + display: inline-flex; + font-size: 14px; + font-weight: 500; + gap: 6px; + margin: 0 16px; + padding: 0; + white-space: nowrap; +} + +.arc-split-btn:hover { opacity: .75; } + +.arc-chapter-row { + border-left: 4px solid #27a844; + cursor: pointer; + transition: background-color .15s; +} + +.arc-chapter-row:hover { + background-color: #f5f7fa; +} + +.arc-chapter-row td { + border-bottom: 1px solid #e9edf2; + border-right: 1px solid #eef2f7; + padding: 13px 12px; + vertical-align: middle; +} + +.arc-chapter-row--before-split td { + border-bottom: none; +} + +.arc-drag-handle { + color: #9ca3af; + cursor: grab; + text-align: center; +} + +.arc-drag-handle .draggable-icon-indicator { + position: static; + top: auto; +} + +.arc-chapter-num { color: #6b7a90; font-size: 13px; } + +.arc-chapter-name { + color: #1a2633; + font-weight: 600; +} + +.arc-chapter-status { color: #4a5568; } + +.arc-chapter-modified { color: #4a5568; font-size: 13px; line-height: 1.3; } + +.arc-modified-sub { color: #6b7a90; font-size: 12px; } + +.arc-chapter-date { color: #6b7a90; } + +.arc-chapter-row .col-actions { + border-right: none; + padding: 0 10px; + text-align: center; + width: 36px; +} + +.arc-add-chapter-btn { + background-color: #007f8c; + border-radius: 4px; + color: #fff; + font-weight: 700; + margin: 14px 16px 16px; +} + +.arc-add-chapter-btn:hover { background-color: #005f6b; color: #fff; } + +.arc-boundary-row td { border: none !important; padding: 0 !important; } +.arc-boundary-header { + align-items: flex-start; + border: 1px solid #b8dbbf; + border-radius: 8px; + display: flex; + justify-content: space-between; + margin: 6px 0; + padding: 12px 14px; +} +.arc-boundary-header--odd { background-color: #eef7ef; border-color: #b8dbbf; } +.arc-boundary-header--even { background-color: #eef3fb; border-color: #bdd0ed; } +.arc-boundary-info { display: flex; flex-direction: column; gap: 2px; } +.arc-boundary-title-row { align-items: center; display: flex; gap: 10px; } +.arc-boundary-flag-badge { + align-items: center; + border-radius: 50%; + color: #fff; + display: inline-flex; + flex-shrink: 0; + font-size: 13px; + height: 34px; + justify-content: center; + width: 34px; +} +.arc-boundary-header--odd .arc-boundary-flag-badge { background-color: #27a844; } +.arc-boundary-header--even .arc-boundary-flag-badge { background-color: #2c6ecb; } +.arc-boundary-title { color: #1f2d3d; font-size: 17px; font-weight: 700; line-height: 1.2; } +.arc-boundary-description { color: #4b5563; font-size: 14px; line-height: 1.3; margin-left: 44px; max-width: 360px; } +.arc-boundary-chapter-count { color: #5f7c88; font-size: 12px; font-weight: 600; margin-left: 44px; } +.arc-boundary-actions { display: flex; gap: 10px; margin-top: 2px; } +.arc-edit-button, +.arc-remove-button { background: transparent; border: none; font-size: 14px; font-weight: 600; padding: 0; white-space: nowrap; } +.arc-edit-button i, .arc-remove-button i { margin-right: 4px; } +.arc-edit-button { color: #198754; } +.arc-remove-button { color: #dc3545; } +.arc-boundary-divider { border-top: 1px solid #5ca66f; } +.split-line-row td { border: none !important; padding: 0 !important; } +.arc-chapter-row td:last-child { + border-right: none; +} +.split-line-container { align-items: center; display: flex; padding: 12px 0; } +.split-line { border-top: 2px dashed #c7cbd1; flex: 1; height: 0; } +.split-into-arc-button { + background: transparent; border: none; color: #1c5cc9; + font-size: 15px; font-weight: 500; margin: 0 14px; padding: 0; white-space: nowrap; +} +.split-into-arc-button i { font-size: 13px; margin-right: 6px; vertical-align: middle; } +.inline-arc-boundary-row { margin: 8px 0; } +.inline-split-line-row { margin: 4px 0; } +.inline-arc-boundary-row .arc-boundary-header { align-items: flex-start; display: flex; justify-content: space-between; } +@media screen and (max-width: 768px) { + .inline-arc-boundary-row .arc-boundary-header { + flex-direction: column; + gap: 12px; + } + .arc-boundary-info { + min-width: 0; + width: 100%; + } + .arc-boundary-description, + .arc-boundary-chapter-count { + margin-left: 44px; + } + .arc-boundary-actions { + flex-wrap: wrap; + margin-left: 44px; + } + .arc-edit-button, + .arc-remove-button { + text-align: left; + white-space: normal; + } +} +@media screen and (max-width: 540px) { + .oppia-edit-arc-modal .modal-dialog { + margin: .5rem; + max-width: calc(100vw - 1rem); + width: auto; + } + .oppia-edit-arc-modal .modal-content { + min-width: 0; + width: 100%; + } + .oppia-edit-arc-modal .modal-footer { + flex-wrap: wrap; + } +} diff --git a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.html b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.html index cbf65e9465d62..be32336b09d7a 100644 --- a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.html +++ b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.html @@ -573,847 +573,4 @@

    Story Card

    - - diff --git a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.spec.ts b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.spec.ts index ee5443f663e66..fd30a0dfa8b85 100644 --- a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.spec.ts +++ b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the story editor component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { diff --git a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.ts b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.ts index 0e46339461596..29dd867ba98dd 100644 --- a/core/templates/pages/story-editor-page/editor-tab/story-editor.component.ts +++ b/core/templates/pages/story-editor-page/editor-tab/story-editor.component.ts @@ -44,10 +44,11 @@ import {PlatformFeatureService} from 'services/platform-feature.service'; import {DateTimeFormatService} from 'services/date-time-format.service'; import constants from 'assets/constants'; import {StoryDomainConstants} from 'domain/story/story-domain.constants'; - +import './story-editor.component.css'; @Component({ selector: 'oppia-story-editor', templateUrl: './story-editor.component.html', + styleUrls: ['./story-editor.component.css'], }) export class StoryEditorComponent implements OnInit, OnDestroy { story!: Story; diff --git a/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.css b/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.css new file mode 100644 index 0000000000000..266d770a213c6 --- /dev/null +++ b/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.css @@ -0,0 +1,184 @@ +.story-node-content-container { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 0 auto; + width: 85%; +} +.story-node-content { + margin-right: 3%; + width: 50%; +} +.story-node-content-info { + margin-right: 5%; + width: 30%; +} +.story-node-card { + margin-bottom: 25px; + margin-top: 0; +} +.story-node-input-card { + margin-bottom: 20px; + margin-top: 0; + padding: 0; +} +.checklist-container { + padding: 10px; +} +.chapter-card-inputs { + padding: 30px 55px 30px 45px; +} +.todo-item-container { + margin-bottom: 5px; +} +.checklist-items .todo-item-container { + display: flex; + margin-bottom: 10%; +} +.checklist-items .todo-item-container .checklist-item-name { + margin-left: 10%; +} +.story-node-header { + margin-bottom: 0; + margin-left: 25px; + padding-top: 35px; +} +.story-node-header span { + font-size: 20px; + font-weight: bold; +} +.story-node-header i { + display: none; +} +.show-chapter-preview-button { + border-top: 1px solid #c1c1c1; + color: #419889; + font-size: 15px; + padding: 10px 0; + width: 100%; +} +.skill-requirements-card { + padding: 35px 25px; +} +.story-skill-requirements { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + margin-top: 10px; +} +.skill-requirements-header { + margin-left: 0; + margin-top: 0; +} +.story-node-header.skill-requirements-header { + padding-top: 0; +} +.prerequisite-skills, +.acquired-skills { + display: inline-block; + width: 48%; +} +.add-skill-btn { + background-color: #008098; + color: #fff; + font-weight: bold; + margin-top: 12px; + text-align: left; +} +.disabled-icon { + cursor: not-allowed; + opacity: 0.4; + pointer-events: none; +} +.skill-header { + border-bottom: 2px solid #000; + margin: 8px 0; +} +.skill-description-card { + border-bottom: 1px solid #e6e6e6; + display: flex; + justify-content: space-between; + padding: 10px 0; +} +.skill-description-card a { + color: inherit; + text-decoration: none; +} +.skill-description-card a:hover { + font-weight: bold; +} +.todo-completed { + color: #00ff7f; +} +.story-node-title, +.story-node-description, +.story-node-exploration, +.story-node-planned-publication-date { + margin-bottom: 25px; +} +.save-button-container { + text-align: right; +} +.save-button-container button { + margin-top: 2px; +} +.story-skill-mobile { + display: none; +} +.oppia-error-message-container { + color: red; + font-size: 14px; + margin-top: 10px; +} +@media screen and (max-width: 768px) { + .story-node-content-container { + width: 100%; + } + .story-node-content { + margin-right: 0; + order: 10; + width: 100%; + } + .story-node-header i { + display: block; + } + .chapter-card-inputs { + padding: 20px 26px 15px; + } + .checklist-items .todo-item-container { + margin-bottom: 5%; + } + .story-node-content-info { + margin-right: 0; + order: 1; + width: 100%; + } + .story-skill-desktop { + display: none; + } + .story-skill-mobile { + display: block; + margin-bottom: 115px; + } + .prerequisite-skills, + .acquired-skills { + width: 100%; + } + .skill-requirements-header { + margin-left: 0; + margin-top: 0; + } + .story-node-header { + align-items: center; + display: flex; + justify-content: space-between; + margin-left: 0; + margin-top: 0; + } + .skill-requirements-card { + padding: 0; + } + .story-skill-requirements { + margin-top: 0; + } +} diff --git a/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.html b/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.html index 4a1b52fdfd725..baa739e41e3fa 100644 --- a/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.html +++ b/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.html @@ -374,190 +374,3 @@ - - diff --git a/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.spec.ts b/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.spec.ts index f2231a4c3d261..4632db1dc43e5 100644 --- a/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.spec.ts +++ b/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.spec.ts @@ -15,6 +15,8 @@ /** * @fileoverview Unit tests for the story node editor directive. */ +// @ts-nocheck + import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { ComponentFixture, diff --git a/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.ts b/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.ts index c924357476eb6..2a3747ddce38a 100644 --- a/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.ts +++ b/core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.ts @@ -42,10 +42,12 @@ import {PageTitleService} from 'services/page-title.service'; import {FocusManagerService} from 'services/stateful/focus-manager.service'; import {StoryEditorStateService} from '../services/story-editor-state.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import './story-node-editor.component.css'; @Component({ selector: 'oppia-story-node-editor', templateUrl: './story-node-editor.component.html', + styleUrls: ['./story-node-editor.component.css'], }) export class StoryNodeEditorComponent implements OnInit, OnDestroy { @Input() nodeId!: string; diff --git a/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.css b/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.css new file mode 100644 index 0000000000000..186e5977e3c59 --- /dev/null +++ b/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.css @@ -0,0 +1,53 @@ +.chapter-input-header span { + font-weight: bold; +} +.chapter-input { + padding-bottom: 15px; +} +.chapter-input-thumbnail { + padding-bottom: 0; + padding-top: 10px; +} +.new-chapter-error-msg { + color: #f00; + font-size: small; +} +.modal-close-button { + display: none; +} +.oppia-new-chapter-title-field { + font-size: 12px; +} +@media screen and (max-width: 768px) { + .modal-dialog { + border: 0; + border-radius: 0; + margin: 0; + padding: 0; + width: 100vw; + } + .modal-content { + border: 0; + border-radius: 0; + height: 100vh; + } + .modal-header { + align-items: center; + background-color: #00609c; + display: flex; + } + .modal-header h3 { + color: #fff; + } + .chapter-input { + margin: 30px 10px; + } + .create-new-chapter-input-field { + margin: 25px 0; + } + .modal-close-button { + color: #fff; + display: block; + font-size: 20px; + } +} diff --git a/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.html b/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.html index ef27c0225718a..d97f9e3c3e22f 100644 --- a/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.html +++ b/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.html @@ -76,60 +76,3 @@

    - - diff --git a/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.spec.ts b/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.spec.ts index c60ea2b21e3f0..7c151973d4cc4 100644 --- a/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.spec.ts +++ b/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for CreateNewChapterModalController. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import { ComponentFixture, diff --git a/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.ts b/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.ts index e61667a111927..78ccc27831fbe 100644 --- a/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.ts +++ b/core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.ts @@ -27,10 +27,12 @@ import {Story} from 'domain/story/story.model'; import {ValidatorsService} from 'services/validators.service'; import {StoryEditorStateService} from '../services/story-editor-state.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import './new-chapter-title-modal.component.css'; @Component({ selector: 'oppia-new-chapter-title-modal', templateUrl: './new-chapter-title-modal.component.html', + styleUrls: ['./new-chapter-title-modal.component.css'], }) export class NewChapterTitleModalComponent implements OnInit { @Input() nodeTitles!: string | string[]; diff --git a/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.css b/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.css new file mode 100644 index 0000000000000..954d79db4e37f --- /dev/null +++ b/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.css @@ -0,0 +1,109 @@ +.modal-header { + font-size: 20px; + font-weight: bold; +} +.modal-body-actions { + display: flex; + justify-content: space-between; +} + +.oppia-reason-select { + border: 1px solid black; + padding: 5px; + width: 35%; +} + +.oppia-reason-select span { + font-weight: bold; + text-decoration: underline; +} + +.oppia-reason-select .oppia-unpublishing-dropdown { + left: 50%; + transform: translate(-50%); + width: 90%; +} + +.oppia-reason-select .oppia-selected-reason-text { + background-color: #dadada; + margin-top: 5px; + padding: 5px; + white-space: normal; + width: 100%; + word-wrap: break-word; +} + +.oppia-reason-select .oppia-unpublishing-dropdown-menu { + width: 100%; +} + +.oppia-reason-select .oppia-unpublishing-dropdown-item { + cursor: pointer; + white-space: normal; +} + +.oppia-warning-and-confirmation { + display: flex; + flex-direction: column; + width: 60%; +} + +.oppia-warning-container { + border: 1px solid black; + padding: 10px; +} + +.oppia-warning-container span { + font-weight: bold; + text-decoration: underline; +} + +.oppia-warning-container .oppia-warning-text { + font-style: italic; + line-height: 100%; + margin-top: 5px; +} + +.oppia-modal-buttons { + display: flex; + justify-content: space-between; + margin-top: 15px; +} + +.oppia-modal-buttons .oppia-cancel-button { + background-color: #fff; + border: 1px solid black; + font-size: 15px; + width: 40%; +} + +.oppia-modal-buttons .oppia-confirm-reason-button { + background-color: #cf3300; + color: #fff; + font-size: 15px; + width: 55%; +} + +@media screen and (max-width: 410px) { + .modal-body-actions { + flex-direction: column; + gap: 15px; + } + + .modal-body-actions .oppia-reason-select,.oppia-warning-and-confirmation { + width: 100%; + } + + .oppia-modal-buttons { + flex-direction: column; + gap: 10px; + } + + .oppia-modal-buttons .oppia-confirm-reason-button { + width: 100%; + } + + .oppia-modal-buttons .oppia-cancel-button { + width: 100%; + } +} diff --git a/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.html b/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.html index 14e403c97f794..e70ba6d1da307 100644 --- a/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.html +++ b/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.html @@ -131,115 +131,3 @@ - - diff --git a/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.ts b/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.ts index b193896f5f599..0ebcf499ba035 100644 --- a/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.ts +++ b/core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.ts @@ -23,10 +23,12 @@ import { STORY_PUBLICATION_ACTION_TEMPORARY_UNPUBLISH, } from 'domain/story/editable-story-backend-api.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import './story-editor-unpublish-modal.component.css'; @Component({ selector: 'oppia-story-editor-unpublish-modal', templateUrl: './story-editor-unpublish-modal.component.html', + styleUrls: ['./story-editor-unpublish-modal.component.css'], }) export class StoryEditorUnpublishModalComponent { constructor( diff --git a/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.css b/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.css new file mode 100644 index 0000000000000..97ccd3b74d975 --- /dev/null +++ b/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.css @@ -0,0 +1,158 @@ +.disabled { + background-color: #cecece; + cursor: not-allowed; + pointer-events: none; +} +.ready-to-publish-mobile-option, .unpublish-chapters-mobile-option { + display: flex; +} +.oppia-editor-warnings-text { + width: 100%; + word-wrap: break-word; +} +.icon { + width: 3.5em; +} +.icon .material-icons { + font-size: 1.4em; + margin-left: 0.3vw; +} +.story-nav-mobile { + display: none; +} +.story-nav-mobile .navbar-tab-icon { + padding: 5px; +} +.oppia-dropdown-menu-container { + margin-right: 5px; + margin-top: 8px; +} +.oppia-changes-count { + margin-left: 2px; + opacity: 0.5; +} +.oppia-dropdown-toggle { + height: 34px; +} +.oppia-dropdown-menu { + min-width: 125px; + right: inherit; +} +.oppia-editor-publish-button-container { + margin-right: 20px; + margin-top: 8px; +} +.story-editor-save-draft-icon { + padding-left: 4px; +} + +@media screen and (max-width: 768px) { + .navbar-desktop li { + display: none; + } + .story-nav-mobile { + display: block; + } + .story-navbar-mobile-tabs { + display: flex; + flex-wrap: wrap; + justify-content: space-evenly; + padding: 10px 0; + } + .save-change-btn-container { + display: flex; + flex-wrap: wrap; + width: 50%; + } + .save-changes-btn { + background-color: #008098; + color: #fff; + font-weight: bold; + width: 80%; + } + .discard-changes-btn { + background-color: #008098; + bottom: 47px; + color: #fff; + font-weight: bold; + left: 12px; + position: absolute; + width: 144px; + } + .btn-publish-topic { + background-color: white; + border: 2px solid #008098; + color: #008098; + font-weight: bold; + } + .arrow-up-container { + align-items: center; + background-color: #fff; + border: 1px solid #808080; + border-radius: 4px; + display: flex; + justify-content: center; + width: 20%; + } + .arrow-up-container i { + color: #008098; + } + .publish-button-container { + width: 40%; + } + .publish-button-container button { + width: 100%; + } + .story-nav-dropdown-container { + position: relative; + text-align: left; + width: 45%; + } + .story-nav-story-nav-dropdown-options { + background-color: #fff; + border: 1px solid #000; + bottom: 100%; + position: absolute; + width: 100%; + } + .story-nav-dropdown-content { + background-color: #fff; + border: 1.5px solid #6d6969; + border-radius: 5px; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + } + .right-container { + background-color: #419889; + color: #fff; + } + .story-nav-dropdown-option { + padding: 5px 0; + } + .story-nav-dropdown-icon { + align-items: center; + background-color: #e6e6e6; + border-left: 1.5px solid #6d6969; + color: #000; + display: flex; + justify-content: center; + text-align: center; + width: 20%; + } + .story-nav-dropdown-text { + padding: 5px 10px; + } + .save-options-dropdown { + width: 52%; + } + .warning-box { + background-color: #f5f55a; + border: 1px solid #000; + bottom: 100%; + position: absolute; + } + .error-icon { + background-color: #f5f55a; + } +} diff --git a/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.html b/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.html index 62c0fa7d7c4fc..b985c30e86fee 100644 --- a/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.html +++ b/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.html @@ -583,163 +583,3 @@ - diff --git a/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.spec.ts b/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.spec.ts index c5149a936995e..436fc35aaa492 100644 --- a/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.spec.ts +++ b/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the story editor navbar component. */ +// @ts-nocheck + import {Story, StoryBackendDict} from 'domain/story/story.model'; import {StoryNode} from 'domain/story/story-node.model'; import {HttpClientTestingModule} from '@angular/common/http/testing'; diff --git a/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.ts b/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.ts index 412afe1b0c180..ba670c534436c 100644 --- a/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.ts +++ b/core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.ts @@ -36,10 +36,12 @@ import {Component, Input, OnInit} from '@angular/core'; import {StoryEditorNavigationService} from '../services/story-editor-navigation.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; import {DraftChapterConfirmationModalComponent} from '../modal-templates/draft-chapter-confirmation-modal.component'; +import './story-editor-navbar.component.css'; @Component({ selector: 'oppia-story-editor-navbar', templateUrl: './story-editor-navbar.component.html', + styleUrls: ['./story-editor-navbar.component.css'], }) export class StoryEditorNavbarComponent implements OnInit { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/story-editor-page/services/story-editor-state.service.spec.ts b/core/templates/pages/story-editor-page/services/story-editor-state.service.spec.ts index 26c7a8c70e995..6942d4aeb16cd 100644 --- a/core/templates/pages/story-editor-page/services/story-editor-state.service.spec.ts +++ b/core/templates/pages/story-editor-page/services/story-editor-state.service.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for StoryEditorStateService. */ +// @ts-nocheck + import {fakeAsync, TestBed, tick} from '@angular/core/testing'; import {HttpClientModule} from '@angular/common/http'; diff --git a/core/templates/pages/story-editor-page/story-editor-page.component.css b/core/templates/pages/story-editor-page/story-editor-page.component.css new file mode 100644 index 0000000000000..22c8c4a0759e1 --- /dev/null +++ b/core/templates/pages/story-editor-page/story-editor-page.component.css @@ -0,0 +1,113 @@ +.story-editor .back-to-topic-button { + margin-bottom: 10px; + margin-left: 9%; + margin-top: 1.5%; +} +.story-editor { + margin-bottom: 80px; +} +.navbar-helper { + background-color: #00609c; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 2px 4px rgba(0, 0, 0, 0.23); + height: 70px; + margin-bottom: 20px; + position: fixed; + text-align: center; + top: 56px; + width: 100%; + z-index: 10; +} +.navbar-tabs { + display: flex; + flex-wrap: wrap; + height: 100%; + justify-content: flex-start; + text-align: center; +} +.navbar-tab-icon { + color: #fff; + display: block; + font-size: 22px; + width: 100%; +} +.navbar-tab span { + color: #fff; + font-size: 12px; +} +.oppia-editor-warnings-box { + cursor: default; + max-width: none; +} +.navbar-tab-active { + background-color: #e6e6e6; + box-shadow: 0 3px 2px 0 #e6e6e6; +} +.navbar-tab-active-icon { + color: #00609c; +} +.topic-warning-container { + display: block; +} +.story-editor-parent { + margin-top: 100px; +} + +@media screen and (max-width: 800px) { + .story-editor-parent { + margin-top: 40px; + } +} + +@media screen and (max-width: 768px) { + .navbar-helper { + display: none; + } +} +.navbar-text { + align-items: center; + color: #fff; + display: flex; + font-family: Capriola, Roboto, Arial, sans-serif; + font-size: 20px; + margin-left: 4%; + width: 35%; +} +.navbar-icons { + display: flex; +} +.nav-list-item { + align-items: center; + cursor: pointer; + display: flex; + justify-content: center; + width: 90px; +} +.navbar-icons .nav-list-item:hover { + background-color: #4eb0c0; +} +.story-editor-warning-box { + cursor: default; + display: block; + max-width: none; +} +.story-warning-count { + display: inline-block; + height: 20px; + margin-left: -10px; + width: 20px; +} + +@media screen and (max-width: 800px) { + .navbar-helper { + position: inherit; + } + .navbar-tabs { + justify-content: center; + } + .navbar-text { + display: none; + } + .topic-editor-main { + margin-top: 16px; + } +} diff --git a/core/templates/pages/story-editor-page/story-editor-page.component.html b/core/templates/pages/story-editor-page/story-editor-page.component.html index 5b33537b30af0..4b003af8b4fb9 100644 --- a/core/templates/pages/story-editor-page/story-editor-page.component.html +++ b/core/templates/pages/story-editor-page/story-editor-page.component.html @@ -54,119 +54,3 @@ - - diff --git a/core/templates/pages/story-editor-page/story-editor-page.component.spec.ts b/core/templates/pages/story-editor-page/story-editor-page.component.spec.ts index b00680ac05be8..fc755bb0891cf 100644 --- a/core/templates/pages/story-editor-page/story-editor-page.component.spec.ts +++ b/core/templates/pages/story-editor-page/story-editor-page.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for story editor page component. */ +// @ts-nocheck + import {EventEmitter} from '@angular/core'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {HttpClientTestingModule} from '@angular/common/http/testing'; diff --git a/core/templates/pages/story-editor-page/story-editor-page.component.ts b/core/templates/pages/story-editor-page/story-editor-page.component.ts index 2a8b6f6d9e51e..2cc9c88b2832e 100644 --- a/core/templates/pages/story-editor-page/story-editor-page.component.ts +++ b/core/templates/pages/story-editor-page/story-editor-page.component.ts @@ -38,10 +38,12 @@ import {BottomNavbarStatusService} from 'services/bottom-navbar-status.service'; import {PreventPageUnloadEventService} from 'services/prevent-page-unload-event.service'; import {UrlService} from 'services/contextual/url.service'; import {Story} from 'domain/story/story.model'; +import './story-editor-page.component.css'; @Component({ selector: 'oppia-story-editor-page', templateUrl: './story-editor-page.component.html', + styleUrls: ['./story-editor-page.component.css'], }) export class StoryEditorPageComponent implements OnInit, OnDestroy { warningsAreShown!: boolean; diff --git a/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.css b/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.css new file mode 100644 index 0000000000000..64b2a2343aa23 --- /dev/null +++ b/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.css @@ -0,0 +1,150 @@ +.parent-container { + background-color: #fff; + border-radius: 4px; + box-shadow: 0 0 8px 6px rgba(0, 0, 0, 0.05); + margin: 0 auto; + min-height: 500px; + padding-top: 30px; + width: 60%; +} +.oppia-story-viewer-desktop { + display: block; +} +.oppia-story-viewer-mobile-card-content { + display: none; +} +.oppia-story-header-text { + display: none; +} +.oppia-story-viewer-title { + color: #00645c; + font-family: Capriola, Roboto, Arial, sans-serif; + font-size: 2.4em; + font-weight: 500; + letter-spacing: 0.58px; + margin-bottom: 0; + padding-left: 60px; + padding-top: 30px; + text-align: left; +} +.oppia-story-viewer-description { + font-family: Roboto, Arial, sans-serif; + font-size: 1.2em; + padding-bottom: 4vh; + padding-left: 60px; + padding-right: 45px; + text-align: left; +} +.oppia-story-viewer-card-container { + padding: 40px 110px; +} +.oppia-story-viewer-card { + color: rgb(51, 51, 51); + flex: 1; + font-family: Roboto, Arial, sans-serif; + margin-bottom: 50px; + margin-left: 30px; + margin-right: 30px; + text-decoration: none; +} + +.oppia-story-viewer-card-content { + display: flex; + flex-wrap: wrap; + margin-top: 16px; +} + +.oppia-story-viewer-card-description-box { + border-radius: 4px; + box-shadow: 0 0 4px #00000029; + display: flex; + flex: 1; + max-height: 180px; + min-width: 120px; + padding: 20px; +} + +.oppia-chapter-title { + font-family: Roboto, Arial, sans-serif; + font-size: 23px; + font-weight: 600; + letter-spacing: 0.43px; + padding-top: 3px; +} + +.oppia-story-viewer-card-img { + margin-bottom: 10px; + margin-right: 10px; +} + +.oppia-description-text { + font-size: 20px; +} + +.node-disabled { + opacity: .5; + pointer-events: none; +} + +@media screen and (max-width: 768px) { + .parent-container { + margin-top: -50px; + padding-bottom: 50px; + padding-top: 0; + width: 100%; + } + .oppia-story-viewer-desktop { + display: none; + } + .oppia-story-viewer-title { + font-size: 20px; + padding-left: 1%; + } + .oppia-story-viewer-description { + display: none; + } + .oppia-story-viewer-card-container { + margin: 0 auto; + padding: 0; + width: 80%; + } + .oppia-story-viewer-card { + margin-bottom: 0; + margin-right: 0; + } + .oppia-story-viewer-mobile-card-content { + border: 2px solid #009c8a; + border-radius: 4px; + display: block; + margin: 0 0 40px; + position: relative; + } + .chapter-complete-icon { + bottom: 10px; + color: #009c8a; + position: absolute; + right: 10px; + } + .story-thumbnail-image { + border-bottom: 2px solid #009c8a; + } + .oppia-chapter-title { + display: block; + font-size: 16px; + margin-left: 0; + padding: 10px 0 0; + } + .oppia-story-viewer-mobile-card-description-box { + margin: 5px 0 12px; + } + .story-text { + margin: 0 auto; + width: 90%; + } + .oppia-story-header-text { + display: block; + font-size: 19px; + margin: 25px auto 6px; + width: 80%; + } +} diff --git a/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.html b/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.html index 37f63c84189b5..b37993a1fb949 100644 --- a/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.html +++ b/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.html @@ -106,155 +106,3 @@ - diff --git a/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.spec.ts b/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.spec.ts index 2406006a19563..a7c96d674cb60 100644 --- a/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.spec.ts +++ b/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for story preview tab component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {EventEmitter} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; @@ -57,13 +59,8 @@ describe('Story Preview tab', () => { providers: [ {provide: PlatformFeatureService, useValue: mockPlatformFeatureService}, { - StoryEditorNavigationService, - provide: [ - { - provide: StoryEditorNavigationService, - UseClass: MockStoryEditorNavigationService, - }, - ], + provide: StoryEditorNavigationService, + useClass: MockStoryEditorNavigationService, }, ], }).compileComponents(); diff --git a/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.ts b/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.ts index 08646a6f3230a..8472547474d86 100644 --- a/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.ts +++ b/core/templates/pages/story-editor-page/story-preview-tab/story-preview-tab.component.ts @@ -25,6 +25,7 @@ import {AssetsBackendApiService} from 'services/assets-backend-api.service'; import {UrlService} from 'services/contextual/url.service'; import {StoryEditorStateService} from '../services/story-editor-state.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import './story-preview-tab.component.css'; interface IconsArray { // Thumbnails for the story nodes are null if they are not yet uploaded. @@ -37,6 +38,7 @@ interface IconsArray { @Component({ selector: 'oppia-story-preview-tab', templateUrl: './story-preview-tab.component.html', + styleUrls: ['./story-preview-tab.component.css'], }) export class StoryPreviewTabComponent implements OnInit, OnDestroy { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.css b/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.css new file mode 100644 index 0000000000000..161b5196a0643 --- /dev/null +++ b/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.css @@ -0,0 +1,34 @@ +.oppia-story-viewer-nav-breadcrumb { + align-items: center; + height: 56px; + padding-top: 0; +} + +.oppia-story-navbar-title { + font-family: 'Roboto', Arial, sans-serif; + font-size: 18px; + margin: 0; +} + +.oppia-story-navbar-topic { + color: white; + font-family: 'Roboto', Arial, sans-serif; + font-size: 12px; +} + +.oppia-story-navbar-topic span { + color: white; +} + +.oppia-story-viewer-nav-separator { + background-color: white; + height: 80%; + margin-left: 12px; + margin-right: 12px; + width: 1px; +} + +.oppia-story-viewer-nav-labels-container { + display: flex; + flex-direction: column; +} diff --git a/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.html b/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.html index 5138f7ba508ad..bd15726b017d3 100644 --- a/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.html +++ b/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.html @@ -25,40 +25,3 @@
    - - diff --git a/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.ts b/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.ts index 169706d77fae7..e297ab970894a 100644 --- a/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.ts +++ b/core/templates/pages/story-viewer-page/navbar-breadcrumb/story-viewer-navbar-breadcrumb.component.ts @@ -28,11 +28,12 @@ import { } from 'services/i18n-language-code.service'; import {TopicViewerBackendApiService} from 'domain/topic_viewer/topic-viewer-backend-api.service'; import {ReadOnlyTopic} from 'domain/topic_viewer/read-only-topic.model'; +import './story-viewer-navbar-breadcrumb.component.css'; @Component({ selector: 'oppia-story-viewer-navbar-breadcrumb', templateUrl: './story-viewer-navbar-breadcrumb.component.html', - styleUrls: [], + styleUrls: ['./story-viewer-navbar-breadcrumb.component.css'], }) export class StoryViewerNavbarBreadcrumbComponent implements OnInit, OnDestroy { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.css b/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.css new file mode 100644 index 0000000000000..72d7842bb5b7e --- /dev/null +++ b/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.css @@ -0,0 +1,5 @@ +@media (max-width: 570px) { + .oppia-story-viewer-back-button { + margin-left: -10px; + } +} diff --git a/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.html b/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.html index 4494ffbf190f4..3794cdecc11fe 100644 --- a/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.html +++ b/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.html @@ -7,10 +7,3 @@ arrow_back - diff --git a/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.ts b/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.ts index aa6fa4cd249f9..7f5279a4d71bc 100644 --- a/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.ts +++ b/core/templates/pages/story-viewer-page/navbar-pre-logo-action/story-viewer-navbar-pre-logo-action.component.ts @@ -23,10 +23,12 @@ import {UrlInterpolationService} from 'domain/utilities/url-interpolation.servic import {Subscription} from 'rxjs'; import {UrlService} from 'services/contextual/url.service'; import {ClassroomDomainConstants} from 'domain/classroom/classroom-domain.constants'; +import './story-viewer-navbar-pre-logo-action.component.css'; @Component({ selector: 'oppia-story-viewer-navbar-pre-logo-action', templateUrl: './story-viewer-navbar-pre-logo-action.component.html', + styleUrls: ['./story-viewer-navbar-pre-logo-action.component.css'], }) export class StoryViewerNavbarPreLogoActionComponent implements OnInit, OnDestroy diff --git a/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.css b/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.css new file mode 100644 index 0000000000000..5b8e5ca2351b9 --- /dev/null +++ b/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.css @@ -0,0 +1,45 @@ +.oppia-subtopic-viewer-nav-breadcrumb { + align-items: center; + display: flex; + flex-direction: column-reverse; + height: 53px; + padding-top: 0; +} + +.oppia-subtopic-viewer-nav-labels-container .topic-name { + font-family: 'Roboto', Arial, sans-serif; +} + +.oppia-subtopic-viewer-nav-separator { + background-color: white; + height: 80%; + margin-bottom: 4px; + margin-right: 12px; + width: 1px; +} + +.oppia-subtopic-viewer-nav-labels-container { + -webkit-box-orient: vertical; + display: -webkit-box; + line-height: normal; + +} + +.oppia-subtopic-title-text { + -webkit-box-orient: vertical; + display: -webkit-box; + font-size: 18px; + -webkit-line-clamp: 1; + margin: 0; + margin-bottom: -3px; + margin-left: -6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: break-spaces; + width: -webkit-fill-available; +} + +.oppia-topic-name-text { + color: white; + font-size: 12px; +} diff --git a/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.html b/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.html index a6ed3f948e9be..9854ceb163ad6 100644 --- a/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.html +++ b/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.html @@ -19,51 +19,3 @@ - - diff --git a/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.ts b/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.ts index f32babe14d3a4..7e548e7245cc4 100644 --- a/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.ts +++ b/core/templates/pages/subtopic-viewer-page/navbar-breadcrumb/subtopic-viewer-navbar-breadcrumb.component.ts @@ -23,6 +23,7 @@ import {ReadOnlySubtopicPageData} from 'domain/subtopic_viewer/read-only-subtopi import {SubtopicViewerBackendApiService} from 'domain/subtopic_viewer/subtopic-viewer-backend-api.service'; import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service'; import {UrlService} from 'services/contextual/url.service'; +import './subtopic-viewer-navbar-breadcrumb.component.css'; import { I18nLanguageCodeService, TranslationKeyType, @@ -31,7 +32,7 @@ import { @Component({ selector: 'subtopic-viewer-navbar-breadcrumb', templateUrl: './subtopic-viewer-navbar-breadcrumb.component.html', - styleUrls: [], + styleUrls: ['./subtopic-viewer-navbar-breadcrumb.component.css'], }) export class SubtopicViewerNavbarBreadcrumbComponent implements OnInit { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.css b/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.css index a53c31844030d..f6fd338dcac83 100644 --- a/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.css +++ b/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.css @@ -124,3 +124,220 @@ padding-right: 18px; } } + +.content-box { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 0 auto; + top: 20vh; + width: 70%; +} +.content { + background-color: #fff; + border-radius: 5px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 30px 35px; + width: 65%; +} +.content-title { + color: #00645c; + display: block; + font-family: Capriola, Arial, sans-serif; + font-size: 30px; + font-weight: 400; + margin-bottom: 15px; + text-align: center; +} +.html-content { + background-color: #ecf5fc; + padding: 15px 25px; +} + +.card { + background-color: #fff; + border: none !important; + overflow: hidden; +} + +.card-header { + background-color: #003b36; + padding: 10px 10px; +} + +.card-title { + color: #fff; + font-family: Roboto, Arial, sans-serif; + font-size: 18px; + font-weight: 700; + margin: 0; + text-align: center; +} + +.card-content { + border: none !important; + padding: 18px 6px; +} + +.new-subtopic-viewer-container { + align-items: center; + display: flex; + flex-direction: column; +} + +.next-actions-buttons-container { + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 30px 0 10px; + width: 45%; +} + +.btn-next-actions { + border-radius: 4px; + box-shadow: 0 0 10px 0 #00000026; + gap: 5px; + height: 60px; + padding: 8px; + width: 200px; +} + +.btn-next-study-guide { + align-items: center; + background-color: #0d48a1; + color: #fff; + display: flex; + flex-direction: row; + justify-content: center; + margin-left: 5px; +} +.text-next-study-guide-fixed { + font-weight: 500; + margin: 0 !important; +} +.text-go-to-study-guides-fixed { + display: inline; + font-weight: 500; +} + +.btn-study-guide-menu { + background-color: #e8f1f0; + border: 1px solid #00645c; + color: #00645c; + margin-right: 5px; +} + +.btn-back-to-topic { + align-self: flex-start; + color: #003b36; + display: block; + font-family: Roboto, Arial, sans-serif; + font-size: 12px; + font-weight: 200; + padding: 0; + text-align: center; +} + +.breadcrumb-container { + display: flex; + margin-bottom: 15px; + width: 45%; +} + +.btn-practice-section { + align-items: center; + align-self: center; + background-color: #f4fafb; + border: 1px solid #008098; + border-radius: 4px; + border-width: 1px; + box-shadow: 0 0 10px 0 #00000026; + color: #008098; + display: flex; + gap: 8px; + justify-content: center; + margin-top: 30px; + padding-left: 16px; + padding-right: 16px; + width: 250px; +} +.text-practice-section-fixed { + font-size: 15px; + font-weight: 500; + margin: 0 !important; +} + +.next-study-guide-forward-arrow { + align-self: flex-start; + padding-top: 2px; +} + +.icon-study-guide-menu { + transform: scaleX(-1); +} + +@media screen and (max-width: 1100px) { + .content-box { + width: 100%; + } + + .breadcrumb-container { + width: 65%; + } + + .next-actions-buttons-container { + width: 65%; + } +} + +@media screen and (max-width: 700px) { + .oppia-subtopic-viewer-container { + padding-top: 40px; + } + .next-actions-buttons-container { + width: 100%; + } + .content, + .content-box { + width: 100%; + } + .content { + padding: 30px; + } + + .card-header { + padding: 15px 15px; + } + + .card-title { + font-size: 18px; + } + + .card-content { + padding: 15px; + } + + .btn-back-to-topic { + align-self: center; + font-size: 16px; + margin-bottom: 30px; + } + + .breadcrumb-container { + justify-content: center; + } + + .btn-study-guide-menu { + padding-left: 0; + } +} + +@media screen and (max-width: 400px) { + .btn-study-guide-menu { + align-items: center; + display: flex; + padding: 2px; + } +} diff --git a/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.html b/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.html index 982486da6f0df..b0e9a5ed72d84 100644 --- a/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.html +++ b/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.html @@ -94,222 +94,3 @@

    {{ section.heading.unicode }}

    - - diff --git a/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.ts b/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.ts index 1ba9dbcdfd912..45372da470639 100644 --- a/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.ts +++ b/core/templates/pages/subtopic-viewer-page/subtopic-viewer-page.component.ts @@ -44,6 +44,7 @@ import {UrlInterpolationService} from 'domain/utilities/url-interpolation.servic import {ClassroomDomainConstants} from 'domain/classroom/classroom-domain.constants'; import {PracticeSessionPageConstants} from 'pages/practice-session-page/practice-session-page.constants'; import {SiteAnalyticsService} from 'services/site-analytics.service'; +import './subtopic-viewer-page.component.css'; @Component({ selector: 'oppia-subtopic-viewer-page', diff --git a/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component-auth.guard.spec.ts b/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component-auth.guard.spec.ts index 1c41e498fd558..6b57b65a43a2d 100644 --- a/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component-auth.guard.spec.ts +++ b/core/templates/pages/technical-feedback-dashboard-page/technical-feedback-dashboard-page.component-auth.guard.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Tests for technical feedback dashboard flag guard. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {TestBed} from '@angular/core/testing'; import { diff --git a/core/templates/pages/terms-page/terms-page.component.css b/core/templates/pages/terms-page/terms-page.component.css new file mode 100644 index 0000000000000..3e3633a95d9f6 --- /dev/null +++ b/core/templates/pages/terms-page/terms-page.component.css @@ -0,0 +1,36 @@ +.oppia-terms-list { + list-style: none; + margin-bottom: 0; + padding-left: 0; + padding-right: 0; + position: relative; + vertical-align: top; + width: 100%; +} +.oppia-page-cards-container .oppia-page-card { + padding: 30px 55px 30px 45px; +} + +.oppia-terms-right-menu { + left: 50%; + margin-left: 360px; + position: fixed; + top: 100px; + width: 300px; +} + +.oppia-terms-right-menu p { + line-height: 1; + margin: 0.5em 0; +} +.oppia-terms-right-menu ul { + list-style-type: none; + padding-left: 20px; +} +.oppia-page-cards-container .oppia-h2-text { + margin-top: 40px; +} + +a { + text-decoration: underline; +} diff --git a/core/templates/pages/terms-page/terms-page.component.html b/core/templates/pages/terms-page/terms-page.component.html index aeae340949535..a3b93163324df 100644 --- a/core/templates/pages/terms-page/terms-page.component.html +++ b/core/templates/pages/terms-page/terms-page.component.html @@ -271,42 +271,3 @@

    Changelog

    Modifications to These Terms

    Changelog

    - - diff --git a/core/templates/pages/terms-page/terms-page.component.ts b/core/templates/pages/terms-page/terms-page.component.ts index 7055dee76ec40..b22cf45acaca8 100644 --- a/core/templates/pages/terms-page/terms-page.component.ts +++ b/core/templates/pages/terms-page/terms-page.component.ts @@ -18,11 +18,12 @@ import {Component} from '@angular/core'; import {AppConstants} from 'app.constants'; +import './terms-page.component.css'; @Component({ selector: 'terms-page', templateUrl: './terms-page.component.html', - styleUrls: [], + styleUrls: ['./terms-page.component.css'], }) export class TermsPageComponent { PAGES_REGISTERED_WITH_FRONTEND = AppConstants.PAGES_REGISTERED_WITH_FRONTEND; diff --git a/core/templates/pages/thanks-page/thanks-page.component.css b/core/templates/pages/thanks-page/thanks-page.component.css new file mode 100644 index 0000000000000..58b8e23683c3d --- /dev/null +++ b/core/templates/pages/thanks-page/thanks-page.component.css @@ -0,0 +1,26 @@ +.thanks-page .oppia-default-height { + height: 450px; +} +.thanks-page .oppia-float-start { + float: left; +} +.thanks-page .oppia-float-end { + float: right; +} +.thanks-page .oppia-otter-image { + height: 250px; +} +.thanks-page .oppia-contribution-use-container { + margin-left: 30px; +} +@media only screen and (max-width: 500px) { + .thanks-page .oppia-default-height { + height: auto; + } + .thanks-page .oppia-float-start { + float: none; + } + .thanks-page .oppia-float-end { + float: none; + } +} diff --git a/core/templates/pages/thanks-page/thanks-page.component.html b/core/templates/pages/thanks-page/thanks-page.component.html index fdce4a07f4dab..6c945bff96ca0 100644 --- a/core/templates/pages/thanks-page/thanks-page.component.html +++ b/core/templates/pages/thanks-page/thanks-page.component.html @@ -38,31 +38,3 @@

    Thank you for donating to The Oppia Foundation!

    - diff --git a/core/templates/pages/thanks-page/thanks-page.component.ts b/core/templates/pages/thanks-page/thanks-page.component.ts index 34e2da2e25fe9..dae49b644421c 100644 --- a/core/templates/pages/thanks-page/thanks-page.component.ts +++ b/core/templates/pages/thanks-page/thanks-page.component.ts @@ -19,11 +19,12 @@ import {Component} from '@angular/core'; import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service'; +import './thanks-page.component.css'; @Component({ selector: 'thanks-page', templateUrl: './thanks-page.component.html', - styleUrls: [], + styleUrls: ['./thanks-page.component.css'], }) export class ThanksPageComponent { constructor(private urlInterpolationService: UrlInterpolationService) {} diff --git a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.css b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.css new file mode 100644 index 0000000000000..fbbc0402f00a0 --- /dev/null +++ b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.css @@ -0,0 +1,123 @@ +.list-item { + background: #fff; + margin: 20px 7.5px 0 12.5%; + padding: 10px 20px; + width: 75%; +} + +.story-list-item { + background: #fff; + font-size: 14px; + margin: 20px 7.5px 0 12.5%; + padding: 10px 20px; + width: 75%; +} + +.story-list-item td { + padding: 2%; +} + +.dashboard-table { + font-size: 15px; + margin-top: 2vh; + width: 100%; +} + +.dashboard-table .dashboard-table-headings, .story-dashboard-table-headings { + border-bottom: 2px solid #000; + margin: 25px 0; +} + +.story-dashboard-table-headings p { + font-size: 13px; + word-spacing: 100vw; +} + +.story-dashboard-table td { + border: 0.5px solid #dbdbdb; +} + +.story-dashboard-table .delete-story-icon { + border: none; +} + +.list-summary, +.list-summary:active, +.list-summary:visited { + color: inherit; + display: block; + height: 2em; + margin: 20px 0; + text-decoration: none; +} + +.story-list-summary, +.story-list-summary:active, +.story-list-summary:visited { + color: inherit; + display: block; + margin: 5px 0; + text-decoration: none; +} + +.list-item:not(:last-child) { + border-bottom: 1px solid #bbb; +} + +.list-item:hover, .story-list-item:hover { + background-color: #ededed; +} + +.list-summary span, .story-list-summary > span { + display: inline-block; + line-height: normal; + vertical-align: middle; + word-break: break-word; +} + +.chapter-count span:last-child { + margin-top: 10px; +} + +.oppia-colgroup-col-1 { + width: 40%; +} + +.oppia-colgroup-col-2 +.oppia-colgroup-col-3 { + width: 30%; +} + +.oppia-colgroup-column-4 { + width: 40%; +} + +.oppia-colgroup-column-1, +.oppia-colgroup-column-2, +.oppia-colgroup-column-3 { + width: 20%; +} + +.cdk-drag-preview { + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); + box-sizing: border-box; +} + +.cdk-drag-placeholder { + opacity: 0; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.list-item:last-child { + border: none; +} + +.story-list.cdk-drop-list-dragging .list-item:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.html b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.html index 3b69f2bd51995..ea8ead9fa180b 100644 --- a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.html +++ b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.html @@ -117,129 +117,3 @@ - - diff --git a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.spec.ts b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.spec.ts index 33ed3ca9873be..b7ea504ee9995 100644 --- a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.spec.ts +++ b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the stories list viewer. */ +// @ts-nocheck + import {CdkDragDrop} from '@angular/cdk/drag-drop'; import {HttpClientTestingModule} from '@angular/common/http/testing'; import { diff --git a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.ts b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.ts index d4ef47e87c3eb..945f24bc5daae 100644 --- a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.ts +++ b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.ts @@ -30,10 +30,12 @@ import {DeleteStoryModalComponent} from '../modal-templates/delete-story-modal.c import {TopicRights} from 'domain/topic/topic-rights.model'; import {TopicEditorStateService} from '../services/topic-editor-state.service'; import {PlatformFeatureService} from 'services/platform-feature.service'; +import './topic-editor-stories-list.component.css'; @Component({ selector: 'oppia-topic-editor-stories-list', templateUrl: './topic-editor-stories-list.component.html', + styleUrls: ['./topic-editor-stories-list.component.css'], }) export class TopicEditorStoriesListComponent implements OnInit { @Input() storySummaries!: StorySummary[]; diff --git a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.css b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.css new file mode 100644 index 0000000000000..62d7d1ce85075 --- /dev/null +++ b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.css @@ -0,0 +1,520 @@ +.topic-editor thumbnail-uploader { + margin-bottom: 15px; +} + +.topic-editor textarea { + resize: vertical; +} + +.topic-editor { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 0 auto; + width: 95%; +} + +.topic-editor .topic-content-container { + margin-right: 3%; + width: 50%; +} + +.topic-editor .topic-info-container { + width: 35%; +} + +.topic-editor .topic-item-card { + margin: 0; +} + +.topic-editor .add-entity-text { + color: #808080; + margin-top: 20px; +} + +.topic-dashboard-link { + font-size: 15px; + margin-bottom: 35px; + margin-left: 4%; +} + +.topic-dashboard-link a { + color: #015f9c; + text-decoration: none; +} + +.topic-editor .topic-preview-container { + text-align: center; +} + +.topic-editor .topic-preview-container button { + margin: 15px 0; +} + +.topic-editor .entity-count { + align-items: center; + background-color: #fff; + border: 1px solid #aaaac1; + border-radius: 6px; + box-shadow: none; + display: flex; + flex-wrap: wrap; + height: 110px; + justify-content: center; + margin: 30px auto 0; + width: 97%; +} + +.topic-editor .subtopic-count, +.topic-editor .skill-count, +.topic-editor .story-count { + border-right: 2px solid #aaaac1; + display: inline-block; + text-align: center; + width: 33%; +} + +.topic-editor .story-count { + border-right: none; +} +.topic-editor .topic-card-header { + margin-bottom: 30px; + margin-left: -20px; +} + +.topic-editor .skills-list-item { + align-items: center; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + position: relative; +} + +.topic-editor .skills-list-item a { + color: inherit; + text-decoration: none; + width: 75%; +} + +.topic-editor .edit-options-container { + margin-right: 14px; +} + +.topic-editor .edit-options-container span { + margin-right: 38px; +} + +.topic-editor .number-skills-header { + margin-right: 30px; +} + +.topic-editor .subtopic-count-value, +.topic-editor .skill-count-value, +.topic-editor .story-count-value { + display: block; + font-size: 26px; + height: 40px; +} + +.topic-editor .topic-editor-main-container { + font-family: Roboto, Arial, sans-serif; + padding: 30px 55px 30px 45px; +} + +.topic-editor .show-topic-preview-button { + border-top: 1px solid #c1c1c1; + color: #33776c; + font-size: 15px; + padding: 10px 0; + width: 100%; +} + +.topic-editor .form-heading { + font-size: 15px; +} + +.topic-editor .topic-name, +.topic-editor .topic-url-fragment, +.topic-editor .topic-meta-tag-content, +.topic-editor .topic-page-title { + margin-bottom: 25px; + margin-top: 32px; +} + +.topic-editor .canonical-stories { + margin-top: 20px; +} + +.topic-editor .topic-description textarea, +.topic-editor .topic-meta-tag-content textarea { + height: 10vh; +} + +.topic-editor .add-subtopic-btn, +.topic-editor .add-skill-btn, +.topic-editor .add-story-btn { + background-color: #008098; + color: #fff; + font-weight: bold; + text-align: left; +} + +.topic-editor .add-subtopic-btn { + width: 140px; +} + +.topic-editor .add-skill-btn { + margin-left: 3%; + width: 108px; +} + +.topic-editor .add-story-btn { + width: 120px; +} + +.topic-editor .item-list-card { + font-family: Roboto, Arial, sans-serif; + padding: 20px 20px 20px 25px; +} + +.topic-editor .list-header { + border-bottom: 2px solid #000; + display: flex; + justify-content: space-between; + margin: 25px 0; +} + +.topic-editor .list-header span { + font-weight: bold; +} + +.topic-editor .subtopics-list-item { + margin: 7px 0; +} + +.topic-editor .subtopics-list-item .draggable-icon-indicator { + padding-right: 5px; +} + +.topic-editor .subtopic-name-header { + align-items: center; + background-color: #e3e3e3; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.2), 0 1px 1px 0 rgba(0, 0, 0, 0.14), 0 2px 1px -1px rgba(0, 0, 0, 0.12); + cursor: pointer; + display: flex; + justify-content: space-between; + padding: 10px; +} + +.topic-editor .skill-option-box { + background-color: #e1dcdc; + border: 1px solid #000; + padding: 0; + position: absolute; + right: -6px; + width: 210px; + z-index: 1; +} + +.topic-editor .uncategorized-skill-option-box { + background-color: #e1dcdc; + border: 1px solid #000; + padding: 0; + position: absolute; + right: 7px; + width: 210px; + z-index: 1; +} + +.topic-editor .skill-edit-option { + border-bottom: 1px solid #000; + cursor: pointer; + font-size: 15px; + padding: 5px; +} + +.topic-editor .uncategorized-skill-edit-option { + border-bottom: 1px solid #000; + cursor: pointer; + font-size: 15px; + padding: 5px; +} + +.topic-editor .edit-option-assign { + border-bottom: none; +} + +.topic-editor .remove-from-topic-option { + border-bottom: none; +} + +.topic-editor .reassign-button { + background: none; + border: none; + color: #0b6c63; +} + +.topic-editor .subtopic-reassign-header { + align-items: center; + display: flex; + justify-content: space-between; +} + +.topic-editor .subtopic-edit-options-container { + position: relative; +} + +.topic-editor .subtopic-edit-options { + background-color: #e1dcdc; + border: 1px solid #2e2626; + padding: 2px; + position: absolute; + right: -7px; + top: 0; + width: 120px; +} + +.topic-editor .skill-description-container { + align-items: center; + display: flex; + justify-content: space-between; + position: relative; +} + +.topic-editor .skills-invalid { + background-color: #c55f4533; + border: 2px solid #c55f45; +} + +.topic-editor .clickable-item:hover { + cursor: pointer; + font-weight: bold; +} + +.topic-editor .subtopic-skill { + border-bottom: 1px solid #e3e3e3; + padding: 8px 10px 8px 32px; +} + +.topic-editor .item-list-card-header i { + display: none; +} + +.topic-editor .subtopic-skill-card-container { + margin-top: 64px; +} + +.topic-editor .diagnostic-test-card-container { + margin-bottom: 64px; + margin-top: 64px; +} + +.topic-editor .practice-tab-info-circle { + background: #000; + border-radius: 10px; + color: #fff; + margin-left: 3px; + padding: 1px; +} + +.topic-editor .practice-tab-checkbox { + box-shadow: rgb(102, 102, 102) 0 0 0 2px inset; +} + +.topic-editor .practice-tab-text { + font-weight: normal; + padding-left: 5px; +} + +.oppia-add-diagnostic-test-skills-button { + color: #00695e; + margin-left: 0; + padding-left: 0; + width: fit-content; +} + +.oppia-flex { + align-items: center; + display: flex; + justify-content: flex-start; + width: fit-content; +} + +.oppia-button-text { + margin-bottom: 0; + padding: 10px; +} + +.oppia-diagnostic-test-dropdown { + width: 50%; +} + +.oppia-remove-diagnostic-test-skill-button { + border-radius: 25px; + color: #00695e; + position: relative; +} + +.oppia-diagnostic-test-skill-description { + width: 85%; +} + +.oppia-diagnostic-test-skill-list-header { + margin-top: 20px; +} + +.oppia-diagnostic-test-card-note { + display: block; +} + +mat-list-item.oppia-diagnostic-test-skill_items-container { + background-color: #e3e3e3; + height: auto; + margin-bottom: 5px; + margin-top: 5px; +} +.oppia-diagnostic-test-skill-items { + align-items: center; + display: flex; + justify-content: space-between; + width: -webkit-fill-available; +} + +.topic-classroom-container { + align-items: start; + display: flex; + flex-direction: column; + gap: 3px; +} + +.topic-classroom-container label { + margin-bottom: 0; +} + +@media screen and (max-width: 1200px) { + .topic-editor .topic-content-container { + width: 50%; + } + + .topic-editor .topic-info-container { + margin-top: 34px; + width: 40%; + } +} + +@media screen and (max-width: 1000px) { + .topic-editor .topic-content-container { + width: 55%; + } + + .topic-editor .topic-info-container { + margin-top: 34px; + width: 45%; + } +} + +@media screen and (max-width: 768px) { + .topic-editor { + margin-bottom: 15px; + width: 100%; + } + + .topic-dashboard-link { + display: none; + } + + .topic-editor .topic-bottom-card { + margin-top: 40px; + } + + .topic-editor .item-list-card { + padding: 0; + } + + .topic-editor .entity-count { + padding-right: 5px; + } + + .topic-editor .subtopic-skill-card-container { + margin-top: inherit; + } + + .topic-editor .item-list-card-header { + align-items: center; + display: flex; + justify-content: space-between; + } + + .topic-editor .topic-editor-main-container { + padding: 0; + } + + .topic-editor .skills-list-item a { + color: inherit; + text-decoration: none; + width: 65%; + } + + .topic-editor .topic-card-header, + .topic-editor .subtopic-reassign-header, + .topic-editor .item-list-card-header h3 { + font-size: 19px; + margin: 0; + } + + .topic-editor .subtopic-skill { + border-bottom: 1px solid #e3e3e3; + padding: 8px 10px 8px 20px; + } + + .topic-editor .item-list-card-header i { + display: inline-block; + font-size: 18px; + } + + .topic-editor .topic-content-container { + margin-right: 0; + margin-top: 30px; + width: 100%; + } + + .topic-editor .subtopic-reassign-header { + width: 100%; + } + + .topic-editor .reassign-button { + display: none; + } + + .topic-editor .topic-info-container { + margin-top: 0; + padding-bottom: 50px; + width: 100%; + } + + .topic-editor .add-subtopic-btn { + width: 44%; + } + + .topic-editor .add-skill-btn { + margin-left: 3%; + } + + .topic-editor .skill-option-box { + right: 40px; + } + + .topic-editor .subtopic-edit-options { + right: 13px; + } + + .topic-editor .uncategorized-skill-option-box { + right: 43px; + } +} + +@media screen and (max-width: 375px) { + .stories-list-container { + padding: 20px 15px 15px; + } +} diff --git a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.html b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.html index b598bb84fba48..f557e147348bc 100644 --- a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.html +++ b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.html @@ -505,525 +505,3 @@

    Canonical Stories

    - diff --git a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.ts b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.ts index 41cef369d7991..681922b90bc96 100644 --- a/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.ts +++ b/core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.ts @@ -45,10 +45,12 @@ import {CdkDragDrop, moveItemInArray} from '@angular/cdk/drag-drop'; import {Topic} from 'domain/topic/topic-object.model'; import {TopicRights} from 'domain/topic/topic-rights.model'; import {RearrangeSkillsInSubtopicsModalComponent} from '../modal-templates/rearrange-skills-in-subtopics-modal.component'; +import './topic-editor-tab.component.css'; @Component({ selector: 'oppia-topic-editor-tab', templateUrl: './topic-editor-tab.component.html', + styleUrls: ['./topic-editor-tab.component.css'], }) export class TopicEditorTabComponent implements OnInit, OnDestroy { skillCreationIsAllowed!: boolean; diff --git a/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.css b/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.css new file mode 100644 index 0000000000000..41f1a38353b2f --- /dev/null +++ b/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.css @@ -0,0 +1,5 @@ +.oppia-radio-group { + display: flex; + flex-direction: column; + margin: 15px 0; +} diff --git a/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.html b/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.html index 946297d4122bb..81d5fbfaa3041 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.html +++ b/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.html @@ -15,10 +15,3 @@

    Assign skill to subtopic

    Assign - diff --git a/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.ts b/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.ts index ad4cbad3292d1..09049a7f77e39 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.ts +++ b/core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.ts @@ -20,10 +20,12 @@ import {Component, OnInit} from '@angular/core'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; import {Subtopic} from 'domain/topic/subtopic.model'; +import './change-subtopic-assignment-modal.component.css'; @Component({ selector: 'oppia-change-subtopic-assignment-modal', templateUrl: './change-subtopic-assignment-modal.component.html', + styleUrls: ['./change-subtopic-assignment-modal.component.css'], }) export class ChangeSubtopicAssignmentModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.css b/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.css new file mode 100644 index 0000000000000..6864f98f57498 --- /dev/null +++ b/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.css @@ -0,0 +1,21 @@ +.create-new-story-header h3 { + font-size: 28px; +} +.story-input { + padding: 10px 10px 16px; +} +input { + width: 70%; +} +.story-input-description input { + width: 95%; +} +.story-input-header span { + font-weight: bold; +} +.story-input-thumbnail { + padding-top: 15px; +} +.ng-touched.ng-empty.required { + outline: 1px solid #f00; +} diff --git a/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.html b/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.html index 723606caf9831..7dff030faadb2 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.html +++ b/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.html @@ -83,26 +83,3 @@

    - diff --git a/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.ts b/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.ts index 0c3472483a2ac..4535b57b7c6ce 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.ts +++ b/core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.ts @@ -25,10 +25,12 @@ import {StoryEditorStateService} from 'pages/story-editor-page/services/story-ed import {WindowRef} from 'services/contextual/window-ref.service'; import {ImageLocalStorageService} from 'services/image-local-storage.service'; import {TopicEditorStateService} from '../services/topic-editor-state.service'; +import './create-new-story-modal.component.css'; @Component({ selector: 'oppia-create-new-story-modal', templateUrl: './create-new-story-modal.component.html', + styleUrls: ['./create-new-story-modal.component.css'], }) export class CreateNewStoryModalComponent extends ConfirmOrCancelModal { constructor( diff --git a/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.css b/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.css new file mode 100644 index 0000000000000..16592f5b65b6b --- /dev/null +++ b/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.css @@ -0,0 +1,61 @@ +.new-subtopic-editor .form-text { + margin-left: 37.5%; + margin-top: -3%; +} +.new-subtopic-editor .thumbnail-editor { + margin: 25px 0; +} +.new-subtopic-editor .description-editor { + margin: 10px 0 0; +} +.new-subtopic-editor .error-msg { + color: #f00; + font-size: smaller; + margin: 10px 0 0; +} +.oppia-create-new-subtopic-container .create-new-subtopic-content { + margin: 15px 0; +} +.oppia-create-new-subtopic-container .oppia-new-subtopic-editable-field { + font-size: 12px; +} +.section-content-box h4 { + margin: 10px 0 5px; +} +.section-content-box .description-editor { + margin-top: 0; +} +@media screen and (max-width: 768px) { + .create-new-subtopic .modal-dialog { + border: 0; + border-radius: 0; + margin: 0; + padding: 0; + width: 100vw; + } + .create-new-subtopic .modal-content { + border: 0; + border-radius: 0; + height: 100vh; + overflow: auto; + } + .create-new-subtopic .modal-header { + align-items: center; + background-color: #00609c; + display: flex; + } + .create-new-subtopic .modal-header h3 { + color: #fff; + } + .create-new-subtopic .create-new-subtopic-input-field { + margin: 25px 0; + } + .create-new-subtopic .modal-close-button { + color: #fff; + display: block; + font-size: 20px; + } + .create-new-subtopic .ng-touched.ng-empty.required { + outline: 1px solid #f00; + } +} diff --git a/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.html b/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.html index 5acae14121dde..a860ec3cd7d92 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.html +++ b/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.html @@ -109,66 +109,3 @@

    Content*

    - diff --git a/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.spec.ts b/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.spec.ts index 7e19e38d6cfe1..d64c182425b59 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.spec.ts +++ b/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the create new subtopic modal component. */ +// @ts-nocheck + import {Topic} from 'domain/topic/topic-object.model'; import { ComponentFixture, diff --git a/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.ts b/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.ts index 5a248265a7d69..3f908566990d2 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.ts +++ b/core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.ts @@ -29,6 +29,7 @@ import {TopicUpdateService} from 'domain/topic/topic-update.service'; import {TopicEditorStateService} from 'pages/topic-editor-page/services/topic-editor-state.service'; import {SubtopicValidationService} from 'pages/topic-editor-page/services/subtopic-validation.service'; import {StudyGuide} from 'domain/topic/study-guide.model'; +import './create-new-subtopic-modal.component.css'; import { CALCULATION_TYPE_CHARACTER, HtmlLengthService, @@ -37,6 +38,7 @@ import { @Component({ selector: 'oppia-create-new-subtopic-modal', templateUrl: './create-new-subtopic-modal.component.html', + styleUrls: ['./create-new-subtopic-modal.component.css'], }) export class CreateNewSubtopicModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.css b/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.css new file mode 100644 index 0000000000000..ae0262a87afc2 --- /dev/null +++ b/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.css @@ -0,0 +1,43 @@ +.oppia-thumbnail-container { + background: #2f6687; + border-radius: 4px 4px 4px 4px; + box-shadow: 0 4.5px 10px rgba(0, 0, 0, 0.25); + color: #fff; + height: 260px; + margin: 0 auto 15px auto; + max-width: 100%; + position: relative; + width: 186px; +} + +.oppia-thumbnail-container img { + border-radius: 4px 4px 0 0; +} + +.oppia-thumbnail-preview-description { + font-size: 16px; + line-height: 1.2; + overflow: hidden; + padding: 0 16px 0 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.oppia-thumbnail-preview-footer { + bottom: 10px; + font-size: 16px; + font-style: italic; + line-height: 1.2; + padding: 0 16px 0 16px; + position: absolute; +} + +.oppia-thumbnail-preview-title { + font-size: 18px; + font-weight: bold; + overflow: hidden; + padding: 10px 16px 10px 16px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.html b/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.html index 29dc890ce57c5..d6854fa57288e 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.html +++ b/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.html @@ -15,48 +15,3 @@ {{ description }} - diff --git a/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.ts b/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.ts index 3db644afd9c8f..70460ab35e673 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.ts +++ b/core/templates/pages/topic-editor-page/modal-templates/preview-thumbnail.component.ts @@ -19,10 +19,12 @@ import {Component, Input} from '@angular/core'; import {PageContextService} from 'services/page-context.service'; import {ImageUploadHelperService} from 'services/image-upload-helper.service'; +import './preview-thumbnail.component.css'; @Component({ selector: 'oppia-preview-thumbnail', templateUrl: './preview-thumbnail.component.html', + styleUrls: ['./preview-thumbnail.component.css'], }) export class PreviewThumbnailComponent { // These properties are initialized using Angular lifecycle hooks diff --git a/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.css b/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.css new file mode 100644 index 0000000000000..182cd4f7b95b6 --- /dev/null +++ b/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.css @@ -0,0 +1,8 @@ +.select-skill-modal .skill-summary-container { + max-height: 50vh; + overflow-y: auto; +} +.select-skill-modal .skill-description { + display: block; + font-size: 1em; +} diff --git a/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.html b/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.html index ac86b0c2677c4..7a9982e3c2e77 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.html +++ b/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.html @@ -90,13 +90,3 @@

    - diff --git a/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.ts b/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.ts index 7a83e56189ff6..dea87244f1453 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.ts +++ b/core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.ts @@ -24,6 +24,7 @@ import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-e import {QuestionsListConstants} from 'components/question-directives/questions-list/questions-list.constants'; import {SkillDifficulty} from 'domain/skill/skill-difficulty.model'; import {SkillSummaryBackendDict} from 'domain/skill/skill-summary.model'; +import './questions-list-select-skill-and-difficulty-modal.component.css'; interface Summary { id: string; @@ -34,6 +35,9 @@ interface Summary { selector: 'oppia-questions-list-select-skill-and-difficulty-modal', templateUrl: './questions-list-select-skill-and-difficulty-modal.component.html', + styleUrls: [ + './questions-list-select-skill-and-difficulty-modal.component.css', + ], }) export class QuestionsListSelectSkillAndDifficultyModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.css b/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.css new file mode 100644 index 0000000000000..0693c405bf527 --- /dev/null +++ b/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.css @@ -0,0 +1,32 @@ +.oppia-close-button-position { + font-size: 2.5rem; + position: absolute; + right: 20px; + top: 12px; +} +.select-skill-modal .skill-summary-container { + max-height: 50vh; + overflow-y: auto; +} +.select-skill-modal .skill-description { + display: block; + font-size: 1em; +} +.loading-screen { + align-items: center; + display: flex; + height: 432.79px; + justify-content: center; +} +@media screen and (max-width: 500px) { + .modal-header { + border-bottom: 0; + margin-bottom: -20px; + } + .oppia-modal-header { + font-size: 22px; + } + .select-skill-modal .skill-description { + font-size: 18px; + } +} diff --git a/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.html b/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.html index 8e906cfb7ce39..87ce1d484ceef 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.html +++ b/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.html @@ -50,38 +50,3 @@

    - - diff --git a/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.spec.ts b/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.spec.ts index 6f1fe30b46905..cec49c27a7fd1 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.spec.ts +++ b/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.spec.ts @@ -17,6 +17,8 @@ * select difficulty modal component. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import { diff --git a/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.ts b/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.ts index 78d3f4055a166..dece2a0efd784 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.ts +++ b/core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.ts @@ -29,11 +29,15 @@ import {ExtractImageFilenamesFromModelService} from 'pages/exploration-player-pa import {AlertsService} from 'services/alerts.service'; import {AssetsBackendApiService} from 'services/assets-backend-api.service'; import {ImageLocalStorageService} from 'services/image-local-storage.service'; +import './questions-opportunities-select-difficulty-modal.component.css'; @Component({ selector: 'oppia-questions-opportunities-select-difficulty-modal', templateUrl: './questions-opportunities-select-difficulty-modal.component.html', + styleUrls: [ + './questions-opportunities-select-difficulty-modal.component.css', + ], }) export class QuestionsOpportunitiesSelectDifficultyModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.css b/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.css new file mode 100644 index 0000000000000..52d00d1c87596 --- /dev/null +++ b/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.css @@ -0,0 +1,115 @@ +.modal-dialog { + max-width: inherit; + width: 80vw; +} +.modal-body { + height: 55vh; + padding: 40px 0 0 0; +} +a { + color: #666; +} +.subtopic-container { + max-height: 60vh; + overflow-y: auto; +} +.uncategorized-skills-container { + max-height: 60vh; + overflow-y: auto; +} +.item-container { + background-color: #eee; + border-radius: 4px; + max-height: 95%; + padding: 5px 10px; + width: 15vw; +} +.rearrange-skills-modal { + display: flex; + height: auto; + margin: 6vh 10vh 0 10vh; +} +.intro-card-message { + color: #808080; + font-size: 0.8em; + line-height: 1.846; + padding: 10px; + text-align: center; +} +.subtopics-container { + align-content: flex-start; + display: flex; + flex-direction: column; + flex-wrap: wrap; + height: 100%; + max-height: 350px; + overflow-x: auto; +} +.subtopic-header { + text-align: left; +} +.item-flex-container { + min-height: 200px; +} +.edit-icon-style { + font-size: 17px; + margin-top: 2px; +} +.skill-id { + background: #fff; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: none; + cursor: move; + margin: 8px 0; + text-align: left; +} +.skill-id:hover { + background-color: #e0e0e0; +} +.skill-card { + margin-left: 12px; +} +.skills-valid { + background-color: #009c8a33; + border: 2px solid #009c8a; +} +.skills-invalid { + background-color: #c55f4533; + border: 2px solid #c55f45; +} +.number-skills-container { + bottom: 3px; + position: relative; +} +.number-skills-text { + color: #666; + font-size: 14px; +} +.empty-placeholder { + height: 100%; + width: 60px; +} +.mat-card { + margin: 8px +} +.outside-container { + min-height: 25px; +} +.cdk-drag-preview { + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); + box-sizing: border-box; + z-index: 99999 !important; +} +.cdk-drag-placeholder { + opacity: 0; +} +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} +.outside-container.cdk-onMoveSkillEnd-list-dragging .skill-id:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.html b/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.html index 1b5fdc9468d56..81933d7c6ec4c 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.html +++ b/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.html @@ -116,120 +116,3 @@

    - diff --git a/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.ts b/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.ts index c82cb2de337ae..8f7c5e000f215 100644 --- a/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.ts +++ b/core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.ts @@ -33,10 +33,12 @@ import { transferArrayItem, } from '@angular/cdk/drag-drop'; import {AppConstants} from 'app.constants'; +import './rearrange-skills-in-subtopics-modal.component.css'; @Component({ selector: 'oppia-rearrange-skills-in-subtopics-modal', templateUrl: './rearrange-skills-in-subtopics-modal.component.html', + styleUrls: ['./rearrange-skills-in-subtopics-modal.component.css'], }) export class RearrangeSkillsInSubtopicsModalComponent extends ConfirmOrCancelModal diff --git a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.css b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.css new file mode 100644 index 0000000000000..3e9b08fbcff04 --- /dev/null +++ b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.css @@ -0,0 +1,4 @@ +.back-to-dashboard { + color: #fff; + text-decoration: none; +} diff --git a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.html b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.html index 9286d215dcb98..42e97cac3aa4e 100644 --- a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.html +++ b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.html @@ -23,9 +23,3 @@ ( You only have rights to edit the topic) - diff --git a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.ts b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.ts index eafa858cccd74..b3b3b544d9049 100644 --- a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.ts +++ b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar-breadcrumb.component.ts @@ -22,10 +22,12 @@ import {Topic} from 'domain/topic/topic-object.model'; import {TopicEditorRoutingService} from '../services/topic-editor-routing.service'; import {TopicEditorStateService} from '../services/topic-editor-state.service'; import {TopicRights} from 'domain/topic/topic-rights.model'; +import './topic-editor-navbar-breadcrumb.component.css'; @Component({ selector: 'oppia-topic-editor-navbar-breadcrumb', templateUrl: './topic-editor-navbar-breadcrumb.component.html', + styleUrls: ['./topic-editor-navbar-breadcrumb.component.css'], }) export class TopicEditorNavbarBreadcrumbComponent { // This property is initialized using Angular lifecycle hooks diff --git a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.css b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.css new file mode 100644 index 0000000000000..4dc182c2f98d0 --- /dev/null +++ b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.css @@ -0,0 +1,164 @@ +.oppia-icon { + width: 3.5em; +} +.oppia-icon .oppia-material-icons { + font-size: 1.4em; + margin-left: 0.3vw; +} +.oppia-topic-nav-mobile { + display: none; +} +.oppia-navbar-dropdown-menu { + margin-right: 5px; + margin-top: 8px; +} +.oppia-change-count { + margin-left: 2px; + opacity: 0.5; +} +.oppia-dropdown-toggle-button { + height: 34px; + margin-left: 5px !important; + +} +.oppia-dropdown-menu { + left: auto; + min-width: 125px; + right: 0; +} +.oppia-editor-publish-button-container { + margin-right: 20px; + margin-top: 8px; +} +.oppia-topic-editor-save-icon { + padding-left: 5px; +} + +@media screen and (max-width: 991px) { + .oppia-editor-publish-button-container { + margin-right: 10px; + margin-top: 8px; + } + .oppia-navbar-desktop { + flex-direction: row; + width: 170px; + } +} + +@media screen and (max-width: 768px) { + .oppia-navbar-desktop { + display: none; + } + .oppia-topic-nav-mobile { + display: block; + } + .oppia-topic-navbar-mobile-tabs { + display: flex; + flex-wrap: wrap; + justify-content: space-evenly; + padding: 10px 0; + } + .oppia-save-change-btn-container { + display: flex; + flex-wrap: wrap; + width: 50%; + } + .oppia-save-changes-btn { + background-color: #008098; + color: #fff; + font-weight: bold; + width: 80%; + } + .oppia-discard-changes-btn { + background-color: #008098; + bottom: 47px; + color: #fff; + font-weight: bold; + left: 12px; + position: absolute; + width: 144px; + } + .oppia-btn-publish-topic { + background-color: white; + border: 2px solid #008098; + color: #008098; + font-weight: bold; + } + .oppia-arrow-up-container { + align-items: center; + background-color: #fff; + border: 1px solid #808080; + border-radius: 4px; + display: flex; + justify-content: center; + width: 20%; + } + .oppia-arrow-up-container i { + color: #008098; + } + .oppia-publish-button-container { + width: 40%; + } + .oppia-publish-button-container button { + width: 100%; + } + .oppia-topic-nav-dropdown-container { + position: relative; + text-align: left; + width: 45%; + } + .oppia-topic-nav-topic-nav-dropdown-options { + background-color: #fff; + border: 1px solid #000; + bottom: 100%; + position: absolute; + width: 100%; + } + .oppia-topic-nav-dropdown-content { + background-color: #fff; + border: 1.5px solid #6d6969; + border-radius: 5px; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + } + + .oppia-right-container { + background-color: #419889; + color: #fff; + } + + .oppia-topic-nav-dropdown-option { + padding: 5px 0; + } + + .oppia-topic-nav-dropdown-icon { + align-items: center; + background-color: #e6e6e6; + border-left: 1.5px solid #6d6969; + color: #000; + display: flex; + justify-content: center; + text-align: center; + width: 20%; + } + .oppia-topic-nav-dropdown-text { + padding: 5px 10px; + } + .oppia-save-options-dropdown { + width: 52%; + } + .oppia-warning-box { + background-color: #f5f55a; + border: 1px solid #000; + bottom: 100%; + position: absolute; + } + .oppia-error-icon { + background-color: #f5f55a; + } + .fas { + font-size: 0.8em; + padding: 5px; + } +} diff --git a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.html b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.html index 2666d500f075c..908e22964515c 100644 --- a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.html +++ b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.html @@ -169,169 +169,3 @@ - diff --git a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.spec.ts b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.spec.ts index 95d84d19bec5b..7410762a941ed 100644 --- a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.spec.ts +++ b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.spec.ts @@ -16,6 +16,8 @@ * @fileoverview Unit tests for the navbar of the topic editor. */ +// @ts-nocheck + import {HttpClientTestingModule} from '@angular/common/http/testing'; import {EventEmitter, NO_ERRORS_SCHEMA} from '@angular/core'; import { diff --git a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.ts b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.ts index 33807f7379f29..bd7f6f57cd9c2 100644 --- a/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.ts +++ b/core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.ts @@ -41,10 +41,12 @@ import {Topic} from 'domain/topic/topic-object.model'; import {TopicRights} from 'domain/topic/topic-rights.model'; import {WindowRef} from 'services/contextual/window-ref.service'; import {PreventPageUnloadEventService} from 'services/prevent-page-unload-event.service'; +import './topic-editor-navbar.component.css'; @Component({ selector: 'oppia-topic-editor-navbar', templateUrl: './topic-editor-navbar.component.html', + styleUrls: ['./topic-editor-navbar.component.css'], }) export class TopicEditorNavbarComponent implements OnInit, OnDestroy, AfterContentChecked diff --git a/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.css b/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.css new file mode 100644 index 0000000000000..e227617c5e896 --- /dev/null +++ b/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.css @@ -0,0 +1,89 @@ +.topic-preview-container { + background-color: #fff; + margin: 0 auto; + position: relative; + top: 50px; + width: 50%; +} +.topic-preview-parent { + margin-top: -40px; +} +.topic-preview-nav { + background-color: #00645c; + box-shadow: 0 2px 8px #000000cf; + margin: 0 auto; + position: relative; + top: 50px; + width: 50%; + z-index: 2; +} +.topic-preview-navbar-tabs { + display: flex; + flex-wrap: wrap; + justify-content: center; +} +.topic-preview-nav-active { + border-bottom: 4px solid #fff; +} +.topic-preview-navbar-tab { + cursor: pointer; + display: inline-block; + flex: 1; + height: 100%; + padding: 20px 0; + text-align: center; +} +.topic-preview-icon img { + height: 40px; +} +.topic-preview-text { + color: #fff; + font-family: Capriola, Roboto, Arial, sans-serif; + font-size: 20px; + margin-top: 4px; + text-transform: uppercase; +} +.topic-preview-container mat-card { + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 0 rgba(0,0,0,0.24); + margin-top: 0; +} +.topic-preview-parent .topic-preview-container mat-card { + box-shadow: none; +} +.topic-preview-tab-content { + margin: 0; + padding: 0; +} +.topic-preview-tab-content > mat-card.oppia-page-card { + margin-bottom: 0; + margin-top: 0; + width: 100%; +} +@media screen and (max-width: 1000px) { + .topic-preview-container { + width: 70%; + } + .topic-preview-nav { + width: 70%; + } +} +@media screen and (max-width: 800px) { + .topic-preview-container { + width: 80%; + } + .topic-preview-parent .background-banner-position { + top: 30px; + } + .topic-preview-nav { + width: 80%; + } +} +@media screen and (max-width: 650px) { + .topic-preview-container { + margin-top: 80px; + width: 100%; + } + .topic-preview-nav { + width: 100%; + } +} diff --git a/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.html b/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.html index e7c8ce7c7318f..575cddf88bf02 100644 --- a/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.html +++ b/core/templates/pages/topic-editor-page/preview-tab/topic-preview-tab.component.html @@ -89,105 +89,3 @@

    , diff --git a/core/tests/.browserstack.env.example b/core/tests/.browserstack.env.example deleted file mode 100644 index 2cb896c22727f..0000000000000 --- a/core/tests/.browserstack.env.example +++ /dev/null @@ -1,11 +0,0 @@ -// This is the sample environment file which is -// required to run e2e tests on Browserstack. -// To run the tests, create a new file, -// .browserstack.env, copy the contents of -// this file in it and then put in the -// actual browserstack credentials. - -USERNAME={Browserstack username} -ACCESS_KEY={Browserstack access key} -DEVICE={The Mobile Device} -OS_VERSION={The OS Version} diff --git a/core/tests/ci-test-suite-configs/acceptance.json b/core/tests/ci-test-suite-configs/acceptance.json index 24001da6a9947..6605df3a5b665 100644 --- a/core/tests/ci-test-suite-configs/acceptance.json +++ b/core/tests/ci-test-suite-configs/acceptance.json @@ -520,6 +520,11 @@ "module": "core/tests/puppeteer-acceptance-tests/specs/translation-submitter/feature-translation-opps-with-new-opps-model/translate-exploration-in-target-language.spec.ts", "framework": "puppeteer" }, + { + "name": "translation-submitter/feature-translation-opps-with-new-opps-model/translate-metadata-and-skill-in-target-language", + "module": "core/tests/puppeteer-acceptance-tests/specs/translation-submitter/feature-translation-opps-with-new-opps-model/translate-metadata-and-skill-in-target-language.spec.ts", + "framework": "puppeteer" + }, { "name": "translation-submitter/check-their-accomplishment", "module": "core/tests/puppeteer-acceptance-tests/specs/translation-submitter/check-their-accomplishment.spec.ts", @@ -550,6 +555,26 @@ "module": "core/tests/puppeteer-acceptance-tests/specs/translation-reviewer/feature-translation-opps-with-new-opps-model/filter-the-translations.spec.ts", "framework": "puppeteer" }, + { + "name": "translation-reviewer/feature-translation-opps-with-new-opps-model/filter-opportunities-by-entity-type", + "module": "core/tests/puppeteer-acceptance-tests/specs/translation-reviewer/feature-translation-opps-with-new-opps-model/filter-opportunities-by-entity-type.spec.ts", + "framework": "puppeteer" + }, + { + "name": "translation-reviewer/feature-translation-opps-with-new-opps-model/review-metadata-and-skill-translations", + "module": "core/tests/puppeteer-acceptance-tests/specs/translation-reviewer/feature-translation-opps-with-new-opps-model/review-metadata-and-skill-translations.spec.ts", + "framework": "puppeteer" + }, + { + "name": "logged-out-user/feature-translation-opps-with-new-opps-model/view-translated-lesson-metadata-and-concept-cards", + "module": "core/tests/puppeteer-acceptance-tests/specs/logged-out-user/feature-translation-opps-with-new-opps-model/view-translated-lesson-metadata-and-concept-cards.spec.ts", + "framework": "puppeteer" + }, + { + "name": "community-library-browser/feature-translation-opps-with-new-opps-model/search-for-an-exploration-using-its-translated-title", + "module": "core/tests/puppeteer-acceptance-tests/specs/community-library-browser/feature-translation-opps-with-new-opps-model/search-for-an-exploration-using-its-translated-title.spec.ts", + "framework": "puppeteer" + }, { "name": "voiceover-submitter/create-delete-and-update-status-of-voiceovers-of-the-explorations", "module": "core/tests/puppeteer-acceptance-tests/specs/voiceover-submitter/create-delete-and-update-status-of-voiceovers-of-the-explorations.spec.ts", diff --git a/core/tests/ci-test-suite-configs/e2e.json b/core/tests/ci-test-suite-configs/e2e.json deleted file mode 100644 index b25cf6ca4b460..0000000000000 --- a/core/tests/ci-test-suite-configs/e2e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "suites": [ - { - "name": "explorationHistoryTab", - "module": "core/tests/webdriverio_desktop/explorationHistoryTab.js" - }, - { - "name": "skillEditor", - "module": "core/tests/webdriverio_desktop/skillEditor.js" - }, - { - "name": "topicAndStoryEditor", - "module": "core/tests/webdriverio_desktop/topicAndStoryEditor.js" - } - ] -} diff --git a/core/tests/data/dummy_ci_tests/dummy_webdriverio.conf.js b/core/tests/data/dummy_ci_tests/dummy_webdriverio.conf.js deleted file mode 100644 index 1126635028e04..0000000000000 --- a/core/tests/data/dummy_ci_tests/dummy_webdriverio.conf.js +++ /dev/null @@ -1,10 +0,0 @@ -var path = require('path') -var suites = { - threeWords: [ - 'webdriverio/threeWords.js' - ], - - fourWords: [ - 'webdriverio_desktop/fourWords.js' - ] -}; diff --git a/core/tests/karma.conf.ts b/core/tests/karma.conf.ts index 817d78c6b0da9..4953b6293dde5 100644 --- a/core/tests/karma.conf.ts +++ b/core/tests/karma.conf.ts @@ -1,52 +1,28 @@ -var argv = require('yargs').positional('terminalEnabled', { - type: 'boolean', - default: false, -}).argv; -var path = require('path'); -var webpack = require('webpack'); -var karma = require('karma'); - -// Here we are checking if the specs_to_run flag is provided or not. If it is -// provided, we are splitting the comma separated string into an array of -// strings. We are then creating a regex pattern to match the spec files -// provided in the specs_to_run flag. We are then using this pattern to create -// a context object which will be used in the webpack.ContextReplacementPlugin -// to only run the spec files provided in the specs_to_run flag. -var specsToRun = []; -if (argv.specs_to_run !== undefined) { - specsToRun = argv.specs_to_run.split(','); -} +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. -const SPECS_PATTERN = - /^(?!.*(puppeteer-acceptance-tests|playwright-acceptance-tests|((valid|invalid)[_-][\w\d.\-])|@nodelib|openapi3-ts|@bcoe)).*((\.s|S)pec\.ts$|(? = SPECS_PATTERN; -if (argv.specs_to_run !== undefined) { - context = specsToRun.reduce( - (context: Record, file: string) => { - if (!SPECS_PATTERN.test(file)) { - return context; - } - const relativeFile: string = `./${file}`; - context[relativeFile] = relativeFile; - return context; - }, - {} - ); -} +var karma = require('karma'); -const webpackPlugins: InstanceType[] = []; -if (argv.specs_to_run !== undefined) { - webpackPlugins.push( - new webpack.ContextReplacementPlugin( - /(:?)/, - path.resolve(__dirname, '..', '..'), - context - ) - ); -} else { - webpackPlugins.push(new webpack.ContextReplacementPlugin(/(:?)/, context)); -} // Generate a random number between 0 and 999 to use as the seed for the // frontend test execution order. let jasmineSeed = Math.floor(Math.random() * 1000); @@ -56,62 +32,13 @@ console.log(`Seed for Frontend Test Execution Order ${jasmineSeed}`); module.exports = function (config: InstanceType) { config.set({ basePath: '../../', - frameworks: ['jasmine'], - files: [ - // Constants must be loaded before everything else. - // Since angular-mocks and math-expressions - // are not bundled, they will be treated separately. - // Note that unexpected errors occur ("Cannot read property 'num' of - // undefined" in MusicNotesInput.js) if the order of core/templates/... - // and extensions/... are switched. The test framework may be flaky. - 'core/templates/**/*_directive.html', - 'core/templates/**/*.directive.html', - 'core/templates/**/*.component.html', - 'core/templates/**/*.template.html', - // This is a file that is generated on running the run_frontend_tests.py - // script. This generated file is a combination of all the spec files - // since Karma is unable to run tests on multiple files due to some - // unknown reason. - 'core/templates/combined-tests.spec.ts', - { - pattern: 'assets/**', - watched: false, - served: true, - included: false, - }, - { - pattern: 'extensions/**/*.png', - watched: false, - served: true, - included: false, - }, - 'extensions/interactions/**/*.component.html', - 'extensions/interactions/*.json', - 'core/tests/data/*.json', - ], - exclude: [ - 'local_compiled_js/core/templates/**/*-e2e.js', - 'local_compiled_js/extensions/**/protractor.js', - 'core/tests/puppeteer-acceptance-tests/*', - 'core/tests/playwright-acceptance-tests/*', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + 'karma-coverage-istanbul-reporter', + 'karma-jasmine', + 'karma-chrome-launcher', + require('@angular-devkit/build-angular/plugins/karma'), ], - proxies: { - // Karma serves files under the /base directory. - // We access files directly in our code, for example /folder/, - // so we need to proxy the requests from /folder/ to /base/folder/. - '/assets/': '/base/assets/', - '/extensions/': '/base/extensions/', - }, - preprocessors: { - 'core/templates/*.ts': ['webpack'], - 'core/templates/**/*.ts': ['webpack'], - 'extensions/**/*.ts': ['webpack'], - // Note that these files should contain only directive templates, and no - // Jinja expressions. They should also be specified within the 'files' - // list above. - 'extensions/interactions/*.json': ['json_fixtures'], - 'core/tests/data/*.json': ['json_fixtures'], - }, client: { jasmine: { random: true, @@ -120,6 +47,11 @@ module.exports = function (config: InstanceType) { }, crossOriginAttribute: true, reporters: ['progress', 'coverage-istanbul'], + // Angular 11 natively uses `karma-coverage-istanbul-reporter` for code coverage + // via the `@angular-devkit/build-angular` plugin. This configuration explicitly directs the output + // to the parent directory to maintain compatibility with existing CI scripts. + // Also note: In angular.json, `"sourceRoot": ""` allows the Angular CLI to discover files in `core/` + // and `extensions/` for instrumentation, since they sit outside a standard `src/` folder. coverageIstanbulReporter: { reports: ['html', 'json', 'lcovonly'], dir: '../karma_coverage_reports/', @@ -131,14 +63,17 @@ module.exports = function (config: InstanceType) { autoWatch: true, browsers: ['CI_Chrome'], // Kill the browser if it does not capture in the given timeout [ms]. - captureTimeout: 60000, + captureTimeout: 120000, browserNoActivityTimeout: 120000, browserDisconnectTimeout: 60000, browserDisconnectTolerance: 3, browserConsoleLogOptions: { level: 'log', format: '%b %T: %m', - terminal: argv.terminalEnabled, + // Note: `KARMA_TERMINAL_ENABLED` is explicitly injected as a process environment + // variable by `scripts/run_frontend_tests.py` when the `--verbose` flag is passed. + // It is not natively provided by the Angular CLI. + terminal: process.env.KARMA_TERMINAL_ENABLED === 'true', }, // Continue running in the background after running tests. singleRun: true, @@ -152,105 +87,9 @@ module.exports = function (config: InstanceType) { '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', - '--js-flags=--max-old-space-size=4096', - ], - }, - }, - - plugins: [ - 'karma-coverage-istanbul-reporter', - 'karma-jasmine', - 'karma-chrome-launcher', - 'karma-json-fixtures-preprocessor', - 'karma-coverage', - 'karma-webpack', - ], - jsonFixturesPreprocessor: { - variableName: '__fixtures__', - }, - - webpack: { - mode: 'development', - resolve: { - modules: [ - 'core/tests/data', - 'assets', - 'core/templates', - 'extensions', - 'node_modules', - 'third_party', - ], - extensions: ['.ts', '.js', '.json', '.html', '.svg', '.png'], - alias: { - // These both are used so that we can refer to them in imports using - // their full path: 'assets/{{filename}}'. - 'assets/constants': 'constants.ts', - 'assets/rich_text_components_definitions': - 'rich_text_components_definitions.ts', - }, - }, - devtool: 'inline-cheap-source-map', - module: { - rules: [ - { - test: /\.ts$/, - use: [ - 'cache-loader', - { - loader: 'ts-loader', - options: { - // Typescript checks do the type checking. - transpileOnly: true, - }, - }, - { - loader: path.resolve( - 'angular-template-style-url-replacer.webpack-loader' - ), - }, - ], - }, - { - test: /\.html$/, - exclude: /(directive|component)\.html$/, - loader: 'underscore-template-loader', - }, - { - test: /(directive|component)\.html$/, - loader: 'html-loader', - options: { - attributes: false, - }, - }, - { - // Exclude all the spec files from the report. - test: /^(?!.*(s|S)pec\.ts$).*\.ts$/, - enforce: 'post', - use: { - loader: 'istanbul-instrumenter-loader', - options: {esModules: true}, - }, - }, - { - test: /\.css$/, - use: [ - { - loader: 'style-loader', - options: { - esModule: false, - }, - }, - { - loader: 'css-loader', - options: { - url: false, - }, - }, - ], - }, + '--js-flags=--max-old-space-size=5120', ], }, - plugins: webpackPlugins, }, }); }; diff --git a/core/tests/playwright-acceptance-tests/playwright.config.ts b/core/tests/playwright-acceptance-tests/playwright.config.ts index c6d27829fc924..cbf0fa3230b14 100644 --- a/core/tests/playwright-acceptance-tests/playwright.config.ts +++ b/core/tests/playwright-acceptance-tests/playwright.config.ts @@ -25,7 +25,7 @@ const PLAYWRIGHT_RESULTS_DIR = path.resolve( ); const isMobile = process.env.MOBILE === 'true'; -const isCI = process.env.PROD_ENV === 'true'; +const isProd = process.env.PROD_ENV === 'true'; export default defineConfig({ expect: { @@ -39,12 +39,12 @@ export default defineConfig({ testDir: './specs', timeout: 300000, fullyParallel: false, - reporter: 'list', + reporter: [['./reporters/compact-reporter.ts']], use: { baseURL: 'http://localhost:8181', headless: process.env.HEADLESS !== 'false', trace: 'retain-on-failure', - screenshot: 'only-on-failure', + screenshot: 'off', video: 'on-first-retry', }, snapshotPathTemplate: @@ -52,13 +52,13 @@ export default defineConfig({ projects: isMobile ? [ { - name: isCI ? 'prod-mobile' : 'dev-mobile', + name: isProd ? 'prod-mobile' : 'dev-mobile', use: {...devices['Pixel 7'], video: 'on'}, }, ] : [ { - name: isCI ? 'prod-desktop' : 'dev-desktop', + name: isProd ? 'prod-desktop' : 'dev-desktop', use: {...devices['Desktop Chrome'], video: 'on'}, }, ], diff --git a/core/tests/playwright-acceptance-tests/reporters/compact-reporter.ts b/core/tests/playwright-acceptance-tests/reporters/compact-reporter.ts new file mode 100644 index 0000000000000..9db77cad7f14f --- /dev/null +++ b/core/tests/playwright-acceptance-tests/reporters/compact-reporter.ts @@ -0,0 +1,99 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Custom Playwright reporter for acceptance tests. + */ + +import type { + Reporter, + TestCase, + TestResult, + FullResult, +} from '@playwright/test/reporter'; + +export default class CompactReporter implements Reporter { + private passed = 0; + private failed = 0; + private skipped = 0; + private lastSuiteTitle: string | null = null; + + onStdOut(chunk: string | Buffer): void { + process.stdout.write(chunk); + } + + onStdErr(chunk: string | Buffer): void { + process.stderr.write(chunk); + } + + onTestEnd(test: TestCase, result: TestResult): void { + const suiteTitle = test.parent.title; + if (suiteTitle !== this.lastSuiteTitle) { + // eslint-disable-next-line no-console + console.log(`\n ${suiteTitle}`); + this.lastSuiteTitle = suiteTitle; + } + + const location = `${test.location.file}:${test.location.line}:${test.location.column}`; + + if (result.status === 'passed') { + this.passed++; + // eslint-disable-next-line no-console + console.log(` ✓ ${test.title} (${result.duration}ms)`); + } else if (result.status === 'skipped') { + this.skipped++; + // eslint-disable-next-line no-console + console.log(` - ${test.title} (skipped)`); + } else { + this.failed++; + // eslint-disable-next-line no-console + console.log(` ✗ ${test.title} (${result.duration}ms)`); + // eslint-disable-next-line no-console + console.log(`\n ${suiteTitle} › ${test.title}\n ${location}\n`); + if (result.error?.snippet) { + // eslint-disable-next-line no-console + console.log(`${result.error.snippet}`); + } + if (result.error?.stack) { + // eslint-disable-next-line no-console + console.log(`\n${result.error.stack}`); + } + + const runningInCI = !!process.env.CI; + if (runningInCI) { + // eslint-disable-next-line no-console + console.log( + '\nDownload the Playwright test results artifact for this job (find it under ' + + '"Summary" > "Artifacts" on the GitHub Actions run) to view screenshots, diffs, ' + + 'and recordings, or drag trace.zip into https://trace.playwright.dev for the full ' + + 'recorded trace.\n' + ); + } else { + // eslint-disable-next-line no-console + console.log( + '\nSee oppia_full_stack_test_playwright_results for screenshots, diffs, and ' + + 'recordings, or drag trace.zip into https://trace.playwright.dev for the full ' + + 'recorded trace.\n' + ); + } + } + } + + onEnd(result: FullResult): void { + // eslint-disable-next-line no-console + console.log( + `\n${this.passed} passed, ${this.failed} failed, ${this.skipped} skipped\n` + ); + } +} diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicAboutPage.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicAboutPage.png index 58094b2db6ad5..00913c5269e9e 100644 Binary files a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicAboutPage.png and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicAboutPage.png differ diff --git a/core/tests/playwright-acceptance-tests/tsconfig.json b/core/tests/playwright-acceptance-tests/tsconfig.json index 4bef7a4a1dc63..f4a0f43e39d0f 100644 --- a/core/tests/playwright-acceptance-tests/tsconfig.json +++ b/core/tests/playwright-acceptance-tests/tsconfig.json @@ -16,6 +16,7 @@ "ignoreDeprecations": "6.0" }, "include": [ + "reporters/**/*.ts", "specs/**/*.ts", "utilities/**/*.ts", "playwright.config.ts" diff --git a/core/tests/playwright-acceptance-tests/utilities/common/playwright-utils.ts b/core/tests/playwright-acceptance-tests/utilities/common/playwright-utils.ts index 489279d0ed14d..27d47a9d69792 100644 --- a/core/tests/playwright-acceptance-tests/utilities/common/playwright-utils.ts +++ b/core/tests/playwright-acceptance-tests/utilities/common/playwright-utils.ts @@ -684,6 +684,8 @@ export class BaseUser { await currentPage.mouse.move(-1, -1); await currentPage.waitForTimeout(5000); + const runningInCI = !!process.env.CI; + const snapshotPath = test .info() .snapshotPath(`${imageName}.png`, {kind: 'screenshot'}); @@ -692,10 +694,18 @@ export class BaseUser { !fs.existsSync(snapshotPath) && process.env.UPDATE_SNAPSHOTS !== 'true' ) { - throw new Error( - `Missing baseline snapshot: ${imageName}.png at ${snapshotPath}. ` + - 'Run with --update_snapshots to generate it.' - ); + let errorMessage = `Missing baseline snapshot: ${imageName}.png at ${snapshotPath}.`; + if (runningInCI) { + errorMessage += + '\r\nWithout a baseline snapshot, the CI run cannot complete.' + + ' Run the update-snapshots workflow to generate baselines for both dev and prod.' + + ' See https://github.com/oppia/oppia/wiki/Acceptance-Tests#screenshots-testing-functionality-in-acceptance-tests-playwright for details.'; + } else { + errorMessage += + '\r\nRun with --update_snapshots to generate it locally, then commit the new screenshot.' + + ' See https://github.com/oppia/oppia/wiki/Acceptance-Tests#screenshots-testing-functionality-in-acceptance-tests-playwright for details.'; + } + throw new Error(errorMessage); } let failureTrigger = 0; @@ -716,10 +726,20 @@ export class BaseUser { } } - await expect(currentPage).toHaveScreenshot(`${imageName}.png`, { - maxDiffPixelRatio: failureTrigger, - ...options, - }); + try { + await expect(currentPage).toHaveScreenshot(`${imageName}.png`, { + maxDiffPixelRatio: failureTrigger, + ...options, + }); + } catch (error) { + let errorMessage = (error as Error).message; + errorMessage += + '\r\nIf the changes to the UI are expected, please update the baseline screenshot(s).' + + '\r\nTo update the screenshot(s), either run the update-snapshots workflow in CI (covers both dev and prod),' + + ' or run locally with --update_snapshots for dev-only baselines.' + + ' See https://github.com/oppia/oppia/wiki/Acceptance-Tests#screenshots-testing-functionality-in-acceptance-tests-playwright for details.'; + throw new Error(errorMessage); + } } /** diff --git a/core/tests/protractor-browserstack.conf.js b/core/tests/protractor-browserstack.conf.js old mode 100755 new mode 100644 diff --git a/core/tests/puppeteer-acceptance-tests/specs/community-library-browser/feature-translation-opps-with-new-opps-model/search-for-an-exploration-using-its-translated-title.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/community-library-browser/feature-translation-opps-with-new-opps-model/search-for-an-exploration-using-its-translated-title.spec.ts new file mode 100644 index 0000000000000..2feffe9a39ac6 --- /dev/null +++ b/core/tests/puppeteer-acceptance-tests/specs/community-library-browser/feature-translation-opps-with-new-opps-model/search-for-an-exploration-using-its-translated-title.spec.ts @@ -0,0 +1,236 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Acceptance test for searching for an exploration using its + * translated title. Only exists when ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS + * is on. + * + * CL.2 Search for an exploration using its translated title. + * + * CUJ Link: https://docs.google.com/spreadsheets/d/1IfKAMEZHl0qJTr0OPo6obImMHXgb-8WM5eAHLfgXsfM/edit + */ + +import testConstants from '../../../utilities/common/test-constants'; +import {UserFactory} from '../../../utilities/common/user-factory'; +import { + Contributor, + CONTENT_TYPE_FILTER, +} from '../../../utilities/user/contributor'; +import {CurriculumAdmin} from '../../../utilities/user/curriculum-admin'; +import { + ExplorationEditor, + INTERACTION_TYPES, +} from '../../../utilities/user/exploration-editor'; +import {LoggedInUser} from '../../../utilities/user/logged-in-user'; +import {LoggedOutUser} from '../../../utilities/user/logged-out-user'; +import {ReleaseCoordinator} from '../../../utilities/user/release-coordinator'; +import {TopicManager} from '../../../utilities/user/topic-manager'; +import {TranslationReviewer} from '../../../utilities/user/translation-reviewer'; +import {TranslationSubmitter} from '../../../utilities/user/translation-submitter'; + +const ROLES = testConstants.Roles; + +Error.stackTraceLimit = 20; + +const TRANSLATION_LANGUAGE = 'हिन्दी (Hindi)'; +const HINDI_SITE_LANGUAGE_CODE = 'hi'; + +const TOPIC_NAME = 'Fractions'; +const SUBTOPIC_NAME = 'Fraction Foundations'; +const SKILL_NAME = 'unit fractions'; +const CHAPTER_NAME = 'Cutting the Pies'; + +const EXPLORATION_TITLE = 'Fair Shares'; +const EXPLORATION_OBJECTIVE = 'Learn dividing a birthday cake into equal parts'; +const EXPLORATION_CARD_CONTENT = 'A birthday cake is cut into equal pieces.'; + +const LESSON_SUBHEADING = `Exploration - ${TOPIC_NAME}`; +const SKILL_SUBHEADING = `Skill - ${TOPIC_NAME}`; + +const CONTENT_TYPE_TITLE = 'title'; +const CONTENT_TYPE_OBJECTIVE = 'objective'; +const CONTENT_TYPE_SKILL_EXPLANATION = 'skill explanation'; + +// A suggestion row truncates its heading at 30 characters, and the heading is +// the translation itself, so any translation looked up by heading below is +// kept under that limit. +const HINDI_TITLE = 'पाई काटना'; +const HINDI_OBJECTIVE = 'केक को बराबर बाँटना सीखें'; +const HINDI_SKILL_EXPLANATION = 'इकाई भिन्न की समीक्षा'; + +const MAX_ITEMS_TO_SKIP = 15; + +// The lesson contributes a title and an objective and the skill contributes an +// explanation, so no list here holds more than a handful of suggestions. The +// bound only exists so a modal that stops closing fails instead of looping. +const MAX_SUGGESTIONS_TO_ACCEPT = 6; + +describe('Community Library Browser: search for an exploration using its translated title', function () { + let loggedOutUser: LoggedOutUser; + let translationReviewer: TranslationReviewer & Contributor & LoggedInUser; + let translationSubmitter: TranslationSubmitter & Contributor & LoggedInUser; + let curriculumAdm: CurriculumAdmin & + ExplorationEditor & + TopicManager & + LoggedInUser; + let releaseCoordinator: ReleaseCoordinator; + let explorationId: string; + + beforeAll(async function () { + translationReviewer = await UserFactory.createNewUser( + 'translationReviewer', + 'translation_reviewer@example.com', + [ROLES.TRANSLATION_REVIEWER], + 'hi' + ); + translationSubmitter = await UserFactory.createNewUser( + 'translationSubmitter', + 'translation_submitter@example.com' + ); + curriculumAdm = await UserFactory.createNewUser( + 'curriculumAdm', + 'curriculumAdm@example.com', + [ROLES.CURRICULUM_ADMIN] + ); + releaseCoordinator = await UserFactory.createNewUser( + 'releaseCoordinator', + 'releaseCoordinator@example.com', + [ROLES.RELEASE_COORDINATOR] + ); + + await releaseCoordinator.enableFeatureFlag( + 'enable_translation_opps_with_new_opp_models' + ); + + await curriculumAdm.navigateToTopicAndSkillsDashboardPage(); + await curriculumAdm.createAndPublishTopic( + TOPIC_NAME, + SUBTOPIC_NAME, + SKILL_NAME + ); + + // The concept card link is what makes the skill's explanation reachable + // from inside the lesson. It is added to the card content here because the + // skill it points at has to exist first. + await curriculumAdm.navigateToCreatorDashboardPage(); + await curriculumAdm.navigateToExplorationEditorFromCreatorDashboard(); + await curriculumAdm.dismissWelcomeModal(); + await curriculumAdm.updateCardContentWithConceptCard( + EXPLORATION_CARD_CONTENT + ); + await curriculumAdm.addInteraction(INTERACTION_TYPES.END_EXPLORATION); + await curriculumAdm.saveExplorationDraft(); + explorationId = await curriculumAdm.publishExplorationWithMetadata( + EXPLORATION_TITLE, + EXPLORATION_OBJECTIVE, + 'Mathematics' + ); + + await curriculumAdm.createAndPublishStoryWithChapter( + 'The Picnic Problem', + 'the-picnic-problem', + CHAPTER_NAME, + explorationId, + TOPIC_NAME + ); + + // Submit Hindi translations for the lesson title, the lesson objective and + // the skill explanation. + await translationSubmitter.navigateToContributorDashboardUsingProfileDropdown(); + await translationSubmitter.switchToTabInContributionDashboard( + 'Translate Text' + ); + await translationSubmitter.selectLanguageFilter(TRANSLATION_LANGUAGE); + await translationSubmitter.selectSubjectInTranslateTextTab(TOPIC_NAME); + + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.LESSONS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_TITLE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput(HINDI_TITLE); + await translationSubmitter.saveTranslationAndMoveToNextItem(); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_OBJECTIVE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput(HINDI_OBJECTIVE); + await translationSubmitter.saveTranslationAndMoveToNextItem( + 'Submitted translation for review.' + ); + await translationSubmitter.closeTranslateTextModal(); + + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.SKILLS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + SKILL_NAME, + SKILL_SUBHEADING + ); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_SKILL_EXPLANATION, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextForRTE(HINDI_SKILL_EXPLANATION); + await translationSubmitter.clickOnElementWithText('Save and close'); + await translationSubmitter.expectToastMessage( + 'Submitted translation for review.' + ); + + // Accept all three, since only an accepted translation reaches a learner. + await translationReviewer.navigateToContributorDashboardUsingProfileDropdown(); + await translationReviewer.filterContentByTopic(TOPIC_NAME); + + await translationReviewer.selectContentTypeFilter( + CONTENT_TYPE_FILTER.LESSONS + ); + await translationReviewer.clickOnTranslateButtonInTranslateTextTabInTranslationReview( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + await translationReviewer.acceptAllSuggestionsInReviewModal( + 'Suggestion accepted.', + MAX_SUGGESTIONS_TO_ACCEPT + ); + + await translationReviewer.selectContentTypeFilter( + CONTENT_TYPE_FILTER.SKILLS + ); + await translationReviewer.acceptAllSuggestionsInReviewModal( + 'Suggestion accepted.', + MAX_SUGGESTIONS_TO_ACCEPT + ); + + loggedOutUser = await UserFactory.createLoggedOutUser(); + }, 2100000); + + it('should search for an exploration using its translated title', async function () { + await loggedOutUser.changeSiteLanguage(HINDI_SITE_LANGUAGE_CODE); + await loggedOutUser.navigateToCommunityLibraryPage(); + await loggedOutUser.searchForLessonInSearchBar(EXPLORATION_TITLE); + + await loggedOutUser.expectLessonTileToShow(HINDI_TITLE, HINDI_OBJECTIVE); + }); + + afterAll(async function () { + await UserFactory.closeAllBrowsers(); + }); +}); diff --git a/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/check-all-user-flow-of-parent-teacher.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/check-all-user-flow-of-parent-teacher.spec.ts index 5b0dfd13a875d..1066f72cd85e0 100644 --- a/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/check-all-user-flow-of-parent-teacher.spec.ts +++ b/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/check-all-user-flow-of-parent-teacher.spec.ts @@ -33,7 +33,7 @@ const DEFAULT_SPEC_TIMEOUT_MSECS = testConstants.DEFAULT_SPEC_TIMEOUT_MSECS; ConsoleReporter.setConsoleErrorsToIgnore([ /http:\/\/localhost:8181\/access_validation_handler\/can_access_classroom_page\?/, /classroom_url_fragment=math Failed to load resource: the server responded with a status of 404 \(Not Found\)/, - /webpack:\/\/\/\.\/core\/templates\/services\/contextual\/logger\.service\.ts\?/, + /ng:\/\/\/\.\/core\/templates\/services\/contextual\/logger\.service\.ts\?/, /The requested path \/learn\/math is not found\./, ]); diff --git a/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/click-all-buttons-on-teach-page.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/click-all-buttons-on-teach-page.spec.ts index 64c75ae4bb4ee..6bddff10b9fcf 100644 --- a/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/click-all-buttons-on-teach-page.spec.ts +++ b/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/click-all-buttons-on-teach-page.spec.ts @@ -27,7 +27,7 @@ const DEFAULT_SPEC_TIMEOUT_MSECS = testConstants.DEFAULT_SPEC_TIMEOUT_MSECS; ConsoleReporter.setConsoleErrorsToIgnore([ /http:\/\/localhost:8181\/access_validation_handler\/can_access_classroom_page\?/, /classroom_url_fragment=math Failed to load resource: the server responded with a status of 404 \(Not Found\)/, - /webpack:\/\/\/\.\/core\/templates\/services\/contextual\/logger\.service\.ts\?/, + /ng:\/\/\/\.\/core\/templates\/services\/contextual\/logger\.service\.ts\?/, /The requested path \/learn\/math is not found\./, ]); diff --git a/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/feature-translation-opps-with-new-opps-model/view-translated-lesson-metadata-and-concept-cards.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/feature-translation-opps-with-new-opps-model/view-translated-lesson-metadata-and-concept-cards.spec.ts new file mode 100644 index 0000000000000..c30963ab3110f --- /dev/null +++ b/core/tests/puppeteer-acceptance-tests/specs/logged-out-user/feature-translation-opps-with-new-opps-model/view-translated-lesson-metadata-and-concept-cards.spec.ts @@ -0,0 +1,266 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Acceptance test for the translated lesson metadata and + * translated concept cards a learner sees once a translation is accepted. + * Both only exist when ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS + * is on. + * + * LO.4 View information about the lesson. + * LO.4 Refer to a concept card. + * LO.4 View translated lesson metadata and concept cards in the player. + * + * CUJ Link: https://docs.google.com/spreadsheets/d/1IfKAMEZHl0qJTr0OPo6obImMHXgb-8WM5eAHLfgXsfM/edit + */ + +import testConstants from '../../../utilities/common/test-constants'; +import {UserFactory} from '../../../utilities/common/user-factory'; +import { + Contributor, + CONTENT_TYPE_FILTER, +} from '../../../utilities/user/contributor'; +import {CurriculumAdmin} from '../../../utilities/user/curriculum-admin'; +import { + ExplorationEditor, + INTERACTION_TYPES, +} from '../../../utilities/user/exploration-editor'; +import {LoggedInUser} from '../../../utilities/user/logged-in-user'; +import {LoggedOutUser} from '../../../utilities/user/logged-out-user'; +import {ReleaseCoordinator} from '../../../utilities/user/release-coordinator'; +import {TopicManager} from '../../../utilities/user/topic-manager'; +import {TranslationReviewer} from '../../../utilities/user/translation-reviewer'; +import {TranslationSubmitter} from '../../../utilities/user/translation-submitter'; + +const ROLES = testConstants.Roles; + +Error.stackTraceLimit = 20; + +const TRANSLATION_LANGUAGE = 'हिन्दी (Hindi)'; +const HINDI_SITE_LANGUAGE_CODE = 'hi'; +// Nothing is translated into Arabic here, so it is the language used to check +// that an untranslated concept card falls back to English. +const UNTRANSLATED_SITE_LANGUAGE_CODE = 'ar'; + +const TOPIC_NAME = 'Fractions'; +const SUBTOPIC_NAME = 'Fraction Foundations'; +const SKILL_NAME = 'unit fractions'; +const CHAPTER_NAME = 'Cutting the Pies'; + +const EXPLORATION_TITLE = 'Fair Shares'; +const EXPLORATION_OBJECTIVE = 'Learn dividing a birthday cake into equal parts'; +const EXPLORATION_CARD_CONTENT = 'A birthday cake is cut into equal pieces.'; + +const LESSON_SUBHEADING = `Exploration - ${TOPIC_NAME}`; +const SKILL_SUBHEADING = `Skill - ${TOPIC_NAME}`; + +const CONTENT_TYPE_TITLE = 'title'; +const CONTENT_TYPE_OBJECTIVE = 'objective'; +const CONTENT_TYPE_SKILL_EXPLANATION = 'skill explanation'; + +// The skill's concept card explanation is built from its description by +// createSkillForTopic, so the English text the fallback shows is known. +const SKILL_EXPLANATION = `Review material text content for ${SKILL_NAME}.`; + +// A suggestion row truncates its heading at 30 characters, and the heading is +// the translation itself, so any translation looked up by heading below is +// kept under that limit. +const HINDI_TITLE = 'पाई काटना'; +const HINDI_OBJECTIVE = 'केक को बराबर बाँटना सीखें'; +const HINDI_SKILL_EXPLANATION = 'इकाई भिन्न की समीक्षा'; + +const MAX_ITEMS_TO_SKIP = 15; + +// The lesson contributes a title and an objective and the skill contributes an +// explanation, so no list here holds more than a handful of suggestions. The +// bound only exists so a modal that stops closing fails instead of looping. +const MAX_SUGGESTIONS_TO_ACCEPT = 6; + +describe('Logged-out User', function () { + let loggedOutUser: LoggedOutUser; + let translationReviewer: TranslationReviewer & Contributor & LoggedInUser; + let translationSubmitter: TranslationSubmitter & Contributor & LoggedInUser; + let curriculumAdm: CurriculumAdmin & + ExplorationEditor & + TopicManager & + LoggedInUser; + let releaseCoordinator: ReleaseCoordinator; + let explorationId: string; + + beforeAll(async function () { + translationReviewer = await UserFactory.createNewUser( + 'translationReviewer', + 'translation_reviewer@example.com', + [ROLES.TRANSLATION_REVIEWER], + 'hi' + ); + translationSubmitter = await UserFactory.createNewUser( + 'translationSubmitter', + 'translation_submitter@example.com' + ); + curriculumAdm = await UserFactory.createNewUser( + 'curriculumAdm', + 'curriculumAdm@example.com', + [ROLES.CURRICULUM_ADMIN] + ); + releaseCoordinator = await UserFactory.createNewUser( + 'releaseCoordinator', + 'releaseCoordinator@example.com', + [ROLES.RELEASE_COORDINATOR] + ); + + await releaseCoordinator.enableFeatureFlag( + 'enable_translation_opps_with_new_opp_models' + ); + + await curriculumAdm.navigateToTopicAndSkillsDashboardPage(); + await curriculumAdm.createAndPublishTopic( + TOPIC_NAME, + SUBTOPIC_NAME, + SKILL_NAME + ); + + // The concept card link is what makes the skill's explanation reachable + // from inside the lesson. It is added to the card content here because the + // skill it points at has to exist first. + await curriculumAdm.navigateToCreatorDashboardPage(); + await curriculumAdm.navigateToExplorationEditorFromCreatorDashboard(); + await curriculumAdm.dismissWelcomeModal(); + await curriculumAdm.updateCardContentWithConceptCard( + EXPLORATION_CARD_CONTENT + ); + await curriculumAdm.addInteraction(INTERACTION_TYPES.END_EXPLORATION); + await curriculumAdm.saveExplorationDraft(); + explorationId = await curriculumAdm.publishExplorationWithMetadata( + EXPLORATION_TITLE, + EXPLORATION_OBJECTIVE, + 'Mathematics' + ); + + await curriculumAdm.createAndPublishStoryWithChapter( + 'The Picnic Problem', + 'the-picnic-problem', + CHAPTER_NAME, + explorationId, + TOPIC_NAME + ); + + // Submit Hindi translations for the lesson title, the lesson objective and + // the skill explanation. + await translationSubmitter.navigateToContributorDashboardUsingProfileDropdown(); + await translationSubmitter.switchToTabInContributionDashboard( + 'Translate Text' + ); + await translationSubmitter.selectLanguageFilter(TRANSLATION_LANGUAGE); + await translationSubmitter.selectSubjectInTranslateTextTab(TOPIC_NAME); + + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.LESSONS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_TITLE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput(HINDI_TITLE); + await translationSubmitter.saveTranslationAndMoveToNextItem(); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_OBJECTIVE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput(HINDI_OBJECTIVE); + await translationSubmitter.saveTranslationAndMoveToNextItem( + 'Submitted translation for review.' + ); + await translationSubmitter.closeTranslateTextModal(); + + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.SKILLS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + SKILL_NAME, + SKILL_SUBHEADING + ); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_SKILL_EXPLANATION, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextForRTE(HINDI_SKILL_EXPLANATION); + await translationSubmitter.clickOnElementWithText('Save and close'); + await translationSubmitter.expectToastMessage( + 'Submitted translation for review.' + ); + + // Accept all three, since only an accepted translation reaches a learner. + await translationReviewer.navigateToContributorDashboardUsingProfileDropdown(); + await translationReviewer.filterContentByTopic(TOPIC_NAME); + + await translationReviewer.selectContentTypeFilter( + CONTENT_TYPE_FILTER.LESSONS + ); + await translationReviewer.clickOnTranslateButtonInTranslateTextTabInTranslationReview( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + await translationReviewer.acceptAllSuggestionsInReviewModal( + 'Suggestion accepted.', + MAX_SUGGESTIONS_TO_ACCEPT + ); + + await translationReviewer.selectContentTypeFilter( + CONTENT_TYPE_FILTER.SKILLS + ); + await translationReviewer.acceptAllSuggestionsInReviewModal( + 'Suggestion accepted.', + MAX_SUGGESTIONS_TO_ACCEPT + ); + + loggedOutUser = await UserFactory.createLoggedOutUser(); + await loggedOutUser.changeSiteLanguage(HINDI_SITE_LANGUAGE_CODE); + }, 2100000); + + it('should cover LO.4: view information about the lesson', async function () { + await loggedOutUser.goto( + `${testConstants.URLs.ExplorationPlayer}${explorationId}` + ); + + await loggedOutUser.openLessonInfoModal(); + await loggedOutUser.expectLessonInfoModalHeaderToBe(HINDI_TITLE); + await loggedOutUser.closeLessonInfoModal(); + }); + + it('should cover LO.4: refer to a concept card', async function () { + await loggedOutUser.expectConceptCardLinkInLessonToWorkProperly( + HINDI_SKILL_EXPLANATION + ); + }); + + it('should cover LO.4: view translated lesson metadata and concept cards in the player with fallback to English if untranslated', async function () { + await loggedOutUser.changeSiteLanguage(UNTRANSLATED_SITE_LANGUAGE_CODE); + await loggedOutUser.goto( + `${testConstants.URLs.ExplorationPlayer}${explorationId}` + ); + + await loggedOutUser.expectConceptCardLinkInLessonToWorkProperly( + SKILL_EXPLANATION + ); + }); + + afterAll(async function () { + await UserFactory.closeAllBrowsers(); + }); +}); diff --git a/core/tests/puppeteer-acceptance-tests/specs/practice-question-coordinator/manage-contributors-question-review-submission-rights.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/practice-question-coordinator/manage-contributors-question-review-submission-rights.spec.ts index 01dcbd8d858cc..eadaf5f68bd89 100644 --- a/core/tests/puppeteer-acceptance-tests/specs/practice-question-coordinator/manage-contributors-question-review-submission-rights.spec.ts +++ b/core/tests/puppeteer-acceptance-tests/specs/practice-question-coordinator/manage-contributors-question-review-submission-rights.spec.ts @@ -187,6 +187,14 @@ describe('Practice Question Coordinator', function () { await questionCoordinator.expectTotalQuestionReviewersToBe(0); }); + it('should show the Question Coordinators tab with stats', async function () { + await questionCoordinator.navigateToContributorDashboardAdminPage(); + await questionCoordinator.switchToTabInContributorAdminPage( + 'Question Coordinators' + ); + await questionCoordinator.expectStatsTableToBeVisible(); + }); + afterAll(async function () { await UserFactory.closeAllBrowsers(); }); diff --git a/core/tests/puppeteer-acceptance-tests/specs/translation-coordinator/manage-contributors-translation-review-rights.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/translation-coordinator/manage-contributors-translation-review-rights.spec.ts index eaf2ace6375e8..bc5423876a492 100644 --- a/core/tests/puppeteer-acceptance-tests/specs/translation-coordinator/manage-contributors-translation-review-rights.spec.ts +++ b/core/tests/puppeteer-acceptance-tests/specs/translation-coordinator/manage-contributors-translation-review-rights.spec.ts @@ -216,6 +216,14 @@ describe('Translation Coordinator', function () { await translationCoordinator.expectNumberOfContributorsToBe(1); }); + it('should show the Translation Coordinators tab with stats', async function () { + await translationCoordinator.navigateToContributorDashboardAdminPage(); + await translationCoordinator.switchToTabInContributorAdminPage( + 'Translation Coordinators' + ); + await translationCoordinator.expectStatsTableToBeVisible(); + }); + afterAll(async function () { await UserFactory.closeAllBrowsers(); }); diff --git a/core/tests/puppeteer-acceptance-tests/specs/translation-reviewer/feature-translation-opps-with-new-opps-model/filter-opportunities-by-entity-type.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/translation-reviewer/feature-translation-opps-with-new-opps-model/filter-opportunities-by-entity-type.spec.ts new file mode 100644 index 0000000000000..5d9beea8bc9ca --- /dev/null +++ b/core/tests/puppeteer-acceptance-tests/specs/translation-reviewer/feature-translation-opps-with-new-opps-model/filter-opportunities-by-entity-type.spec.ts @@ -0,0 +1,232 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Acceptance test for the "Content Type" filter on the review tab. + * Only exists when ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS is on. + * + * TR.1 Filter opportunities by Entity Type. + * + * CUJ Link: https://docs.google.com/spreadsheets/d/1IfKAMEZHl0qJTr0OPo6obImMHXgb-8WM5eAHLfgXsfM/edit + */ + +import testConstants from '../../../utilities/common/test-constants'; +import {UserFactory} from '../../../utilities/common/user-factory'; +import { + Contributor, + CONTENT_TYPE_FILTER, +} from '../../../utilities/user/contributor'; +import {CurriculumAdmin} from '../../../utilities/user/curriculum-admin'; +import { + ExplorationEditor, + INTERACTION_TYPES, +} from '../../../utilities/user/exploration-editor'; +import {LoggedInUser} from '../../../utilities/user/logged-in-user'; +import {ReleaseCoordinator} from '../../../utilities/user/release-coordinator'; +import {TopicManager} from '../../../utilities/user/topic-manager'; +import {TranslationReviewer} from '../../../utilities/user/translation-reviewer'; +import {TranslationSubmitter} from '../../../utilities/user/translation-submitter'; + +const ROLES = testConstants.Roles; + +Error.stackTraceLimit = 20; + +const TRANSLATION_LANGUAGE = 'हिन्दी (Hindi)'; + +const TOPIC_NAME = 'Fractions'; +const SUBTOPIC_NAME = 'Fraction Foundations'; +const SKILL_NAME = 'unit fractions'; +const CHAPTER_NAME = 'Cutting the Pies'; + +const EXPLORATION_TITLE = 'Fair Shares'; +const EXPLORATION_OBJECTIVE = 'Learn dividing a birthday cake into equal parts'; + +const LESSON_SUBHEADING = `Exploration - ${TOPIC_NAME}`; +const SKILL_SUBHEADING = `Skill - ${TOPIC_NAME}`; + +const CONTENT_TYPE_TITLE = 'title'; +const CONTENT_TYPE_OBJECTIVE = 'objective'; +const CONTENT_TYPE_SKILL_DESCRIPTION = 'skill description'; +const CONTENT_TYPE_SKILL_EXPLANATION = 'skill explanation'; + +// A suggestion row truncates its heading at 30 characters, and the heading is +// the translation itself, so any translation looked up by heading below is +// kept under that limit. +const HINDI_TITLE = 'पाई काटना'; +const HINDI_OBJECTIVE = 'केक को बराबर हिस्सों में बाँटना सीखें'; +const HINDI_SKILL_DESCRIPTION = 'इकाई भिन्न'; +const HINDI_SKILL_EXPLANATION = 'इकाई भिन्न की समीक्षा'; + +// The action button an opportunity card carries on the review tab. It opens +// the card's own suggestions rather than a review, which is what "Review" on +// each individual suggestion inside does. +const OPPORTUNITY_ACTION_BUTTON_LABEL = 'Translations'; + +const MAX_ITEMS_TO_SKIP = 15; + +describe('Translation Reviewer: filter opportunities by entity type', function () { + let translationReviewer: TranslationReviewer & Contributor & LoggedInUser; + let translationSubmitter: TranslationSubmitter & Contributor & LoggedInUser; + let curriculumAdm: CurriculumAdmin & ExplorationEditor & TopicManager; + let releaseCoordinator: ReleaseCoordinator; + + beforeAll(async function () { + translationReviewer = await UserFactory.createNewUser( + 'translationReviewer', + 'translation_reviewer@example.com', + [ROLES.TRANSLATION_REVIEWER], + 'hi' + ); + translationSubmitter = await UserFactory.createNewUser( + 'translationSubmitter', + 'translation_submitter@example.com' + ); + curriculumAdm = await UserFactory.createNewUser( + 'curriculumAdm', + 'curriculumAdm@example.com', + [ROLES.CURRICULUM_ADMIN] + ); + releaseCoordinator = await UserFactory.createNewUser( + 'releaseCoordinator', + 'releaseCoordinator@example.com', + [ROLES.RELEASE_COORDINATOR] + ); + + await releaseCoordinator.enableFeatureFlag( + 'enable_translation_opps_with_new_opp_models' + ); + + await curriculumAdm.navigateToTopicAndSkillsDashboardPage(); + await curriculumAdm.createAndPublishTopic( + TOPIC_NAME, + SUBTOPIC_NAME, + SKILL_NAME + ); + + await curriculumAdm.navigateToCreatorDashboardPage(); + await curriculumAdm.navigateToExplorationEditorFromCreatorDashboard(); + await curriculumAdm.dismissWelcomeModal(); + await curriculumAdm.updateCardContent( + 'A birthday cake is cut into equal pieces.' + ); + await curriculumAdm.addInteraction(INTERACTION_TYPES.END_EXPLORATION); + await curriculumAdm.saveExplorationDraft(); + const explorationId = await curriculumAdm.publishExplorationWithMetadata( + EXPLORATION_TITLE, + EXPLORATION_OBJECTIVE, + 'Mathematics' + ); + + await curriculumAdm.createAndPublishStoryWithChapter( + 'The Picnic Problem', + 'the-picnic-problem', + CHAPTER_NAME, + explorationId, + TOPIC_NAME + ); + + // Submit the metadata and skill translations that this spec reviews. + await translationSubmitter.navigateToContributorDashboardUsingProfileDropdown(); + await translationSubmitter.switchToTabInContributionDashboard( + 'Translate Text' + ); + await translationSubmitter.selectLanguageFilter(TRANSLATION_LANGUAGE); + await translationSubmitter.selectSubjectInTranslateTextTab(TOPIC_NAME); + + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.LESSONS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_TITLE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput(HINDI_TITLE); + await translationSubmitter.saveTranslationAndMoveToNextItem(); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_OBJECTIVE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput(HINDI_OBJECTIVE); + await translationSubmitter.saveTranslationAndMoveToNextItem( + 'Submitted translation for review.' + ); + await translationSubmitter.closeTranslateTextModal(); + + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.SKILLS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + SKILL_NAME, + SKILL_SUBHEADING + ); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_SKILL_DESCRIPTION, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput( + HINDI_SKILL_DESCRIPTION + ); + await translationSubmitter.saveTranslationAndMoveToNextItem(); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_SKILL_EXPLANATION, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextForRTE(HINDI_SKILL_EXPLANATION); + await translationSubmitter.clickOnElementWithText('Save and close'); + await translationSubmitter.expectToastMessage( + 'Submitted translation for review.' + ); + }, 2100000); + + it('should Filter opportunities by Entity Type', async function () { + await translationReviewer.navigateToContributorDashboardUsingProfileDropdown(); + await translationReviewer.filterContentByTopic(TOPIC_NAME); + await translationReviewer.selectContentTypeFilter(CONTENT_TYPE_FILTER.ALL); + + // The lesson and the skill are listed together, each carrying the action + // button that opens its own suggestions. + await translationReviewer.expectOpportunityActionButtonToBe( + CHAPTER_NAME, + LESSON_SUBHEADING, + OPPORTUNITY_ACTION_BUTTON_LABEL + ); + await translationReviewer.expectOpportunityActionButtonToBe( + SKILL_NAME, + SKILL_SUBHEADING, + OPPORTUNITY_ACTION_BUTTON_LABEL + ); + }); + + it('should Filter opportunities by Entity Type for skills', async function () { + await translationReviewer.selectContentTypeFilter( + CONTENT_TYPE_FILTER.SKILLS + ); + + // The skills filter shows the suggestions straight away, so the control + // that returns from a lesson's suggestions to the lesson list is absent. + await translationReviewer.expectBackToLessonsControlToBeVisible(false); + await translationReviewer.expectOpportunityToBePresent( + HINDI_SKILL_DESCRIPTION, + SKILL_NAME + ); + }); + + afterAll(async function () { + await UserFactory.closeAllBrowsers(); + }); +}); diff --git a/core/tests/puppeteer-acceptance-tests/specs/translation-reviewer/feature-translation-opps-with-new-opps-model/review-metadata-and-skill-translations.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/translation-reviewer/feature-translation-opps-with-new-opps-model/review-metadata-and-skill-translations.spec.ts new file mode 100644 index 0000000000000..4b0121f5b218c --- /dev/null +++ b/core/tests/puppeteer-acceptance-tests/specs/translation-reviewer/feature-translation-opps-with-new-opps-model/review-metadata-and-skill-translations.spec.ts @@ -0,0 +1,282 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Acceptance test for reviewing exploration metadata and skill + * translations. Only exists when ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS is on. + * + * TR.2 Review exploration metadata translations. + * TR.2 Review skill translations. + * TR.2 Accept a translation. + * TR.2 Reject a translation. + * + * CUJ Link: https://docs.google.com/spreadsheets/d/1IfKAMEZHl0qJTr0OPo6obImMHXgb-8WM5eAHLfgXsfM/edit + */ + +import testConstants from '../../../utilities/common/test-constants'; +import {UserFactory} from '../../../utilities/common/user-factory'; +import { + Contributor, + CONTENT_TYPE_FILTER, +} from '../../../utilities/user/contributor'; +import {CurriculumAdmin} from '../../../utilities/user/curriculum-admin'; +import { + ExplorationEditor, + INTERACTION_TYPES, +} from '../../../utilities/user/exploration-editor'; +import {LoggedInUser} from '../../../utilities/user/logged-in-user'; +import {ReleaseCoordinator} from '../../../utilities/user/release-coordinator'; +import {TopicManager} from '../../../utilities/user/topic-manager'; +import {TranslationReviewer} from '../../../utilities/user/translation-reviewer'; +import {TranslationSubmitter} from '../../../utilities/user/translation-submitter'; + +const ROLES = testConstants.Roles; + +Error.stackTraceLimit = 20; + +const TRANSLATION_LANGUAGE = 'हिन्दी (Hindi)'; + +const TOPIC_NAME = 'Fractions'; +const SUBTOPIC_NAME = 'Fraction Foundations'; +const SKILL_NAME = 'unit fractions'; +const CHAPTER_NAME = 'Cutting the Pies'; + +const EXPLORATION_TITLE = 'Fair Shares'; +const EXPLORATION_OBJECTIVE = 'Learn dividing a birthday cake into equal parts'; + +const LESSON_SUBHEADING = `Exploration - ${TOPIC_NAME}`; +const SKILL_SUBHEADING = `Skill - ${TOPIC_NAME}`; + +const CONTENT_TYPE_TITLE = 'title'; +const CONTENT_TYPE_OBJECTIVE = 'objective'; +const CONTENT_TYPE_SKILL_DESCRIPTION = 'skill description'; +const CONTENT_TYPE_SKILL_EXPLANATION = 'skill explanation'; + +// A suggestion row truncates its heading at 30 characters, and the heading is +// the translation itself, so any translation looked up by heading below is +// kept under that limit. +const HINDI_TITLE = 'पाई काटना'; +const HINDI_OBJECTIVE = 'केक को बराबर हिस्सों में बाँटना सीखें'; +const HINDI_SKILL_DESCRIPTION = 'इकाई भिन्न'; +const HINDI_SKILL_EXPLANATION = 'इकाई भिन्न की समीक्षा'; + +// The review modal walks through the suggestions that follow the row that was +// opened, so it names the next one until the last is reached. +const ACCEPT_AND_REVIEW_NEXT_LABEL = 'Accept and review next'; +const ACCEPT_LABEL = 'Accept'; +const REJECT_LABEL = 'Reject'; + +const MAX_ITEMS_TO_SKIP = 15; + +describe('Translation Reviewer: review translations', function () { + let translationReviewer: TranslationReviewer & Contributor & LoggedInUser; + let translationSubmitter: TranslationSubmitter & Contributor & LoggedInUser; + let curriculumAdm: CurriculumAdmin & ExplorationEditor & TopicManager; + let releaseCoordinator: ReleaseCoordinator; + + beforeAll(async function () { + translationReviewer = await UserFactory.createNewUser( + 'translationReviewer', + 'translation_reviewer@example.com', + [ROLES.TRANSLATION_REVIEWER], + 'hi' + ); + translationSubmitter = await UserFactory.createNewUser( + 'translationSubmitter', + 'translation_submitter@example.com' + ); + curriculumAdm = await UserFactory.createNewUser( + 'curriculumAdm', + 'curriculumAdm@example.com', + [ROLES.CURRICULUM_ADMIN] + ); + releaseCoordinator = await UserFactory.createNewUser( + 'releaseCoordinator', + 'releaseCoordinator@example.com', + [ROLES.RELEASE_COORDINATOR] + ); + + await releaseCoordinator.enableFeatureFlag( + 'enable_translation_opps_with_new_opp_models' + ); + + await curriculumAdm.navigateToTopicAndSkillsDashboardPage(); + await curriculumAdm.createAndPublishTopic( + TOPIC_NAME, + SUBTOPIC_NAME, + SKILL_NAME + ); + + await curriculumAdm.navigateToCreatorDashboardPage(); + await curriculumAdm.navigateToExplorationEditorFromCreatorDashboard(); + await curriculumAdm.dismissWelcomeModal(); + await curriculumAdm.updateCardContent( + 'A birthday cake is cut into equal pieces.' + ); + await curriculumAdm.addInteraction(INTERACTION_TYPES.END_EXPLORATION); + await curriculumAdm.saveExplorationDraft(); + const explorationId = await curriculumAdm.publishExplorationWithMetadata( + EXPLORATION_TITLE, + EXPLORATION_OBJECTIVE, + 'Mathematics' + ); + + await curriculumAdm.createAndPublishStoryWithChapter( + 'The Picnic Problem', + 'the-picnic-problem', + CHAPTER_NAME, + explorationId, + TOPIC_NAME + ); + + // Submit the metadata and skill translations that this spec reviews. + await translationSubmitter.navigateToContributorDashboardUsingProfileDropdown(); + await translationSubmitter.switchToTabInContributionDashboard( + 'Translate Text' + ); + await translationSubmitter.selectLanguageFilter(TRANSLATION_LANGUAGE); + await translationSubmitter.selectSubjectInTranslateTextTab(TOPIC_NAME); + + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.LESSONS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_TITLE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput(HINDI_TITLE); + await translationSubmitter.saveTranslationAndMoveToNextItem(); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_OBJECTIVE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput(HINDI_OBJECTIVE); + await translationSubmitter.saveTranslationAndMoveToNextItem( + 'Submitted translation for review.' + ); + await translationSubmitter.closeTranslateTextModal(); + + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.SKILLS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + SKILL_NAME, + SKILL_SUBHEADING + ); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_SKILL_DESCRIPTION, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextInTranslationInput( + HINDI_SKILL_DESCRIPTION + ); + await translationSubmitter.saveTranslationAndMoveToNextItem(); + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_SKILL_EXPLANATION, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.typeTextForRTE(HINDI_SKILL_EXPLANATION); + await translationSubmitter.clickOnElementWithText('Save and close'); + await translationSubmitter.expectToastMessage( + 'Submitted translation for review.' + ); + + // "Review Translations" is not a dashboard tab, it is the side navigation + // item a reviewer already lands on when translations are their only review + // right, so the dashboard opens on the list this spec reviews from. + await translationReviewer.navigateToContributorDashboardUsingProfileDropdown(); + await translationReviewer.filterContentByTopic(TOPIC_NAME); + }, 2100000); + + it('should Review skill translations', async function () { + // Reaching a skill's suggestions through its opportunity card is a + // different path from the skills filter above, which skips the card, so + // both are covered. + await translationReviewer.selectContentTypeFilter(CONTENT_TYPE_FILTER.ALL); + await translationReviewer.clickOnTranslateButtonInTranslateTextTabInTranslationReview( + SKILL_NAME, + SKILL_SUBHEADING + ); + await translationReviewer.expectOpportunityToBePresent( + HINDI_SKILL_EXPLANATION, + SKILL_NAME + ); + }); + + it('should Accept a translation and Reject a translation', async function () { + // The skill has two pending suggestions. Opening the first row means one + // suggestion still follows it, which is what makes the labels below + // deterministic rather than dependent on how the list is sorted. + await translationReviewer.openFirstSuggestionForReview(); + + await translationReviewer.expectReviewButtonLabelToBe( + 'accept', + ACCEPT_AND_REVIEW_NEXT_LABEL + ); + await translationReviewer.submitTranslationReviewAndExpectToast( + 'accept', + 'Suggestion accepted.' + ); + + // Accepting the first suggestion leaves the modal open on the second and + // last one, where both buttons drop the mention of a next suggestion. + await translationReviewer.expectReviewButtonLabelToBe( + 'accept', + ACCEPT_LABEL + ); + await translationReviewer.expectReviewButtonLabelToBe( + 'reject', + REJECT_LABEL + ); + await translationReviewer.submitTranslationReviewAndExpectToast( + 'reject', + 'Suggestion rejected.', + 'Please match the wording used in the lesson.' + ); + }); + + it('should Review exploration metadata translations', async function () { + await translationReviewer.selectContentTypeFilter( + CONTENT_TYPE_FILTER.LESSONS + ); + await translationReviewer.clickOnTranslateButtonInTranslateTextTabInTranslationReview( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + + // A lesson's suggestions are reached through its opportunity card, so the + // control that returns to the lesson list is present here. + await translationReviewer.expectBackToLessonsControlToBeVisible(); + + await translationReviewer.startTranslationReview( + HINDI_TITLE, + `${TOPIC_NAME} / ${CHAPTER_NAME}` + ); + await translationReviewer.expectCardContentToBeInTranslationReview( + HINDI_TITLE + ); + await translationReviewer.submitTranslationReviewAndExpectToast( + 'accept', + 'Suggestion accepted.' + ); + }); + + afterAll(async function () { + await UserFactory.closeAllBrowsers(); + }); +}); diff --git a/core/tests/puppeteer-acceptance-tests/specs/translation-submitter/feature-translation-opps-with-new-opps-model/translate-metadata-and-skill-in-target-language.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/translation-submitter/feature-translation-opps-with-new-opps-model/translate-metadata-and-skill-in-target-language.spec.ts new file mode 100644 index 0000000000000..ac124d030bd83 --- /dev/null +++ b/core/tests/puppeteer-acceptance-tests/specs/translation-submitter/feature-translation-opps-with-new-opps-model/translate-metadata-and-skill-in-target-language.spec.ts @@ -0,0 +1,352 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Acceptance test for translating exploration metadata and + * skills, and for the "Content Type" filter that selects between them. Both + * only exist when ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS is on. + * + * TS.1 Filter opportunities by Entity Type. + * TS.2 Translate exploration metadata. + * TS.2 Translate a skill. + * TS.2 View the translation I submitted. + * + * CUJ Link: https://docs.google.com/spreadsheets/d/1IfKAMEZHl0qJTr0OPo6obImMHXgb-8WM5eAHLfgXsfM/edit + */ + +import testConstants from '../../../utilities/common/test-constants'; +import {UserFactory} from '../../../utilities/common/user-factory'; +import { + Contributor, + CONTENT_TYPE_FILTER, +} from '../../../utilities/user/contributor'; +import {CurriculumAdmin} from '../../../utilities/user/curriculum-admin'; +import { + ExplorationEditor, + INTERACTION_TYPES, +} from '../../../utilities/user/exploration-editor'; +import {LoggedInUser} from '../../../utilities/user/logged-in-user'; +import {ReleaseCoordinator} from '../../../utilities/user/release-coordinator'; +import {TopicManager} from '../../../utilities/user/topic-manager'; +import {TranslationSubmitter} from '../../../utilities/user/translation-submitter'; + +const ROLES = testConstants.Roles; + +Error.stackTraceLimit = 20; + +const TRANSLATION_LANGUAGE = 'हिन्दी (Hindi)'; + +const TOPIC_NAME = 'Fractions'; +const SUBTOPIC_NAME = 'Fraction Foundations'; +const SKILL_NAME = 'unit fractions'; +const CHAPTER_NAME = 'Cutting the Pies'; + +const EXPLORATION_TITLE = 'Fair Shares'; +const EXPLORATION_OBJECTIVE = 'Learn dividing a birthday cake into equal parts'; +const EXPLORATION_CATEGORY = 'Mathematics'; +const EXPLORATION_TAG = 'fractions'; + +// The subheadings the opportunity list gives each content type, built from the +// entity type and the topic the opportunity belongs to. +const LESSON_SUBHEADING = `Exploration - ${TOPIC_NAME}`; +const SKILL_SUBHEADING = `Skill - ${TOPIC_NAME}`; + +// The content types as the translation modal spells them out to a contributor. +const CONTENT_TYPE_TITLE = 'title'; +const CONTENT_TYPE_OBJECTIVE = 'objective'; +const CONTENT_TYPE_SKILL_DESCRIPTION = 'skill description'; +const CONTENT_TYPE_SKILL_EXPLANATION = 'skill explanation'; + +// The skill's concept card explanation is built from its description by +// createSkillForTopic, so the source text of the explanation item is known. +const SKILL_EXPLANATION = `Review material text content for ${SKILL_NAME}.`; + +const HINDI_TITLE = 'पाई काटना'; +const HINDI_OBJECTIVE = + 'जन्मदिन के केक को बराबर हिस्सों में बाँटना सीखें और भिन्नों को समझें'; +const HINDI_SKILL_DESCRIPTION = 'इकाई भिन्न'; +const HINDI_SKILL_EXPLANATION = 'इकाई भिन्न के लिए समीक्षा सामग्री।'; + +// A lesson title translation is capped at the width the lesson tile can show. +// Anything longer is refused with the message below and the save button is +// disabled until it is shortened. +const TITLE_CHARACTER_LIMIT = 36; +const OVERLONG_HINDI_TITLE = 'पाई काटना और बराबर हिस्सों में बाँटना सीखिए'; +const TITLE_LENGTH_ERROR = + 'Translation exceeds the allowed character limit. The translation for ' + + `the above content must be ${TITLE_CHARACTER_LIMIT} characters or fewer.`; + +// The number of items the modal may serve before the one under test. A lesson +// has one item per card plus one per populated metadata field, so this is +// generous enough to reach any of them and small enough to fail rather than +// loop forever. +const MAX_ITEMS_TO_SKIP = 15; + +describe('Translation Submitter', function () { + let translationSubmitter: TranslationSubmitter & Contributor & LoggedInUser; + let curriculumAdm: CurriculumAdmin & ExplorationEditor & TopicManager; + let releaseCoordinator: ReleaseCoordinator; + + beforeAll(async function () { + translationSubmitter = await UserFactory.createNewUser( + 'translator', + 'translator@example.com' + ); + curriculumAdm = await UserFactory.createNewUser( + 'curriculumAdm', + 'curriculumAdm@example.com', + [ROLES.CURRICULUM_ADMIN] + ); + releaseCoordinator = await UserFactory.createNewUser( + 'releaseCoordinator', + 'releaseCoordinator@example.com', + [ROLES.RELEASE_COORDINATOR] + ); + + await releaseCoordinator.enableFeatureFlag( + 'enable_translation_opps_with_new_opp_models' + ); + + // A skill only gets a translation opportunity while it is assigned to a + // topic, so the skill is created through the topic that owns it. + await curriculumAdm.navigateToTopicAndSkillsDashboardPage(); + await curriculumAdm.createAndPublishTopic( + TOPIC_NAME, + SUBTOPIC_NAME, + SKILL_NAME + ); + + await curriculumAdm.navigateToCreatorDashboardPage(); + await curriculumAdm.navigateToExplorationEditorFromCreatorDashboard(); + await curriculumAdm.dismissWelcomeModal(); + await curriculumAdm.updateCardContent( + 'A birthday cake is cut into equal pieces.' + ); + await curriculumAdm.addInteraction(INTERACTION_TYPES.END_EXPLORATION); + await curriculumAdm.saveExplorationDraft(); + + // The tag gives the lesson a tag metadata item to translate, alongside the + // title, objective and category items. + const explorationId = await curriculumAdm.publishExplorationWithMetadata( + EXPLORATION_TITLE, + EXPLORATION_OBJECTIVE, + EXPLORATION_CATEGORY, + EXPLORATION_TAG + ); + + await curriculumAdm.createAndPublishStoryWithChapter( + 'The Picnic Problem', + 'the-picnic-problem', + CHAPTER_NAME, + explorationId, + TOPIC_NAME + ); + }, 2100000); + + it('should Filter opportunities by Entity Type', async function () { + await translationSubmitter.navigateToContributorDashboardUsingProfileDropdown(); + await translationSubmitter.switchToTabInContributionDashboard( + 'Translate Text' + ); + await translationSubmitter.selectLanguageFilter(TRANSLATION_LANGUAGE); + await translationSubmitter.selectSubjectInTranslateTextTab(TOPIC_NAME); + + // The filter offers exactly three options and starts on "All". + await translationSubmitter.expectSelectedContentTypeFilterToBe( + CONTENT_TYPE_FILTER.ALL + ); + await translationSubmitter.expectContentTypeFilterOptionsToBe([ + CONTENT_TYPE_FILTER.ALL, + CONTENT_TYPE_FILTER.LESSONS, + CONTENT_TYPE_FILTER.SKILLS, + ]); + + // "All" lists the lesson and the skill together, each under a subheading + // naming its own content type. + await translationSubmitter.selectContentTypeFilter(CONTENT_TYPE_FILTER.ALL); + await translationSubmitter.expectOpportunityToBePresent( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + await translationSubmitter.expectOpportunityToBePresent( + SKILL_NAME, + SKILL_SUBHEADING + ); + + // "Lessons" drops the skill. + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.LESSONS + ); + await translationSubmitter.expectOpportunityToBePresent( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + await translationSubmitter.expectOpportunityToBePresent( + SKILL_NAME, + SKILL_SUBHEADING, + false + ); + + // "Skills" drops the lesson. + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.SKILLS + ); + await translationSubmitter.expectOpportunityToBePresent( + SKILL_NAME, + SKILL_SUBHEADING + ); + await translationSubmitter.expectOpportunityToBePresent( + CHAPTER_NAME, + LESSON_SUBHEADING, + false + ); + }); + + it('should not show the content type filter on tabs that cannot use it', async function () { + // "Submit Question" is not checked here because that tab is only shown to + // a user who can suggest questions, which a translator cannot. + await translationSubmitter.switchToTabInContributionDashboard( + 'My Contributions' + ); + await translationSubmitter.navigateToTabInMyContributions( + 'Contribution Stats' + ); + await translationSubmitter.expectContentTypeFilterToBeVisible(false); + + await translationSubmitter.navigateToTabInMyContributions('Badges'); + await translationSubmitter.expectContentTypeFilterToBeVisible(false); + }); + + it('should Translate exploration metadata', async function () { + await translationSubmitter.switchToTabInContributionDashboard( + 'Translate Text' + ); + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.LESSONS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + CHAPTER_NAME, + LESSON_SUBHEADING + ); + + // The title item names its content type in words and shows the English + // title as its source text. + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_TITLE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.expectTranslationInstructionToBe( + CONTENT_TYPE_TITLE, + TRANSLATION_LANGUAGE + ); + await translationSubmitter.expectTextToTranslateToBe(EXPLORATION_TITLE); + + // A title translation over the limit is refused and blocks the save. + await translationSubmitter.typeTextInTranslationInput(OVERLONG_HINDI_TITLE); + await translationSubmitter.expectTranslationErrorToBe(TITLE_LENGTH_ERROR); + await translationSubmitter.expectSaveTranslationButtonToBeEnabled(false); + + // Shortening it clears the error and allows the save. + await translationSubmitter.typeTextInTranslationInput(HINDI_TITLE); + await translationSubmitter.expectNoTranslationErrors(); + await translationSubmitter.expectSaveTranslationButtonToBeEnabled(); + await translationSubmitter.saveTranslationAndMoveToNextItem(); + + // The limit is the title's alone, so the same length is accepted for the + // objective. + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_OBJECTIVE, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.expectTextToTranslateToBe(EXPLORATION_OBJECTIVE); + await translationSubmitter.typeTextInTranslationInput(HINDI_OBJECTIVE); + await translationSubmitter.expectNoTranslationErrors(); + await translationSubmitter.expectSaveTranslationButtonToBeEnabled(); + await translationSubmitter.saveTranslationAndMoveToNextItem( + 'Submitted translation for review.' + ); + await translationSubmitter.closeTranslateTextModal(); + }); + + it('should Translate a skill', async function () { + await translationSubmitter.selectContentTypeFilter( + CONTENT_TYPE_FILTER.SKILLS + ); + await translationSubmitter.clickOnTranslateButtonInTranslateTextTab( + SKILL_NAME, + SKILL_SUBHEADING + ); + + // A skill's items are its description, then its concept card explanation. + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_SKILL_DESCRIPTION, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.expectTranslationInstructionToBe( + CONTENT_TYPE_SKILL_DESCRIPTION, + TRANSLATION_LANGUAGE + ); + await translationSubmitter.expectTextToTranslateToBe(SKILL_NAME); + await translationSubmitter.typeTextInTranslationInput( + HINDI_SKILL_DESCRIPTION + ); + await translationSubmitter.saveTranslationAndMoveToNextItem(); + + await translationSubmitter.skipToTranslationItemOfContentType( + CONTENT_TYPE_SKILL_EXPLANATION, + MAX_ITEMS_TO_SKIP + ); + await translationSubmitter.expectTranslationInstructionToBe( + CONTENT_TYPE_SKILL_EXPLANATION, + TRANSLATION_LANGUAGE + ); + await translationSubmitter.expectTextToTranslateToBe(SKILL_EXPLANATION); + await translationSubmitter.typeTextForRTE(HINDI_SKILL_EXPLANATION); + await translationSubmitter.clickOnElementWithText('Save and close'); + + await translationSubmitter.expectToastMessage( + 'Submitted translation for review.' + ); + }); + + it('should View the translation I submitted', async function () { + // The contributions list is filtered by the Content Type selector, which + // the previous test left on "Skills". The selector is only shown on the + // "Translate Text" tab, so it is reset here before switching across, + // otherwise this test would only ever see one of the two contributions. + await translationSubmitter.selectContentTypeFilter(CONTENT_TYPE_FILTER.ALL); + await translationSubmitter.switchToTabInContributionDashboard( + 'My Contributions' + ); + + // A metadata translation is listed against the lesson it belongs to. + await translationSubmitter.expectContributionStatusToBe( + HINDI_TITLE, + `${TOPIC_NAME} / ${CHAPTER_NAME}`, + 'Awaiting review' + ); + + // A skill translation is listed under the skill's description rather than + // under its id or a placeholder for a deleted opportunity. + await translationSubmitter.expectContributionStatusToBe( + HINDI_SKILL_DESCRIPTION, + SKILL_NAME, + 'Awaiting review' + ); + }); + + afterAll(async function () { + await UserFactory.closeAllBrowsers(); + }); +}); diff --git a/core/tests/puppeteer-acceptance-tests/utilities/common/console-reporter.ts b/core/tests/puppeteer-acceptance-tests/utilities/common/console-reporter.ts index 03fc296cc2fcb..fcf95d3d6bd39 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/common/console-reporter.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/common/console-reporter.ts @@ -109,7 +109,7 @@ const CONSOLE_ERRORS_TO_FIX = [ ), // TODO(#20829): Console error "Cannot read properties of undefined (reading 'getStory')" on navigation or reload in Story Editor. /Cannot read properties of undefined \(reading 'getStory'\)/, - /Occurred at http:\/\/localhost:8181\/story_editor\/.*\/#\/chapter_editor\/node_1 webpack:\/\/\/\..* Cannot read properties of undefined \(reading 'getStory'\)/, + /Occurred at http:\/\/localhost:8181\/story_editor\/.*\/#\/chapter_editor\/node_1 .* Cannot read properties of undefined \(reading 'getStory'\)/, // TODO(#20830): ExpressionChangedAfterItHasBeenCheckedError in Story Editor. /ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'headerText: Story Editor'. Current value: 'headerText: Chapter Editor'./, ]; diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/contributor-admin.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/contributor-admin.ts index 2965742b4488b..f0027e75a36a7 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/user/contributor-admin.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/user/contributor-admin.ts @@ -33,6 +33,7 @@ const lastDatePickerInputSelector = '.e2e-test-last-date-picker-input'; const mobileLastDatePickerInputSelector = '.e2e-test-mobile-last-date-picker-input'; const statsListItemSelector = '.e2e-test-stats-list-item'; +const statsTableSelector = '.e2e-test-stats-table'; const tabSelectionDropdownMobileSelector = '.e2e-test-tab-selection-dropdown'; const newContributorAdminDashboardPageSelector = '.e2e-test-new-contributor-admin-dashboard-page'; @@ -64,6 +65,8 @@ export class ContributorAdmin extends BaseUser { | 'Translation Reviewers' | 'Question Submitters' | 'Question Reviewers' + | 'Translation Coordinators' + | 'Question Coordinators' ): Promise { if (this.isViewportAtMobileWidth()) { // Remove last 's' from the tab name. @@ -165,6 +168,13 @@ export class ContributorAdmin extends BaseUser { number ); } + + /** + * Checks that the contributor stats table has rendered (i.e. stats loaded). + */ + async expectStatsTableToBeVisible(): Promise { + await this.expectElementToBeVisible(statsTableSelector); + } } export let ContributorAdminFactory = (): ContributorAdmin => diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/contributor.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/contributor.ts index 1a258f1048747..ed44cba03e598 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/user/contributor.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/user/contributor.ts @@ -67,6 +67,26 @@ const featuredLanguageExplainationSelector = const languageDropdownToggleArrowSelector = '.e2e-test-language-dropdown-toggle-arrow'; +const entityTypeSelector = '.e2e-test-entity-type-selector'; +const selectedEntityTypeSelector = '.e2e-test-entity-type-selector-selected'; +const entityTypeOptionSelector = '.e2e-test-entity-type-selector-option'; + +// How long to wait when checking whether the "Content Type" dropdown is +// already open. The dropdown is toggled by a local click with no network call +// behind it, so this only has to cover a render, and keeping it well below the +// default probe timeout keeps the common closed case cheap. +const dropdownStateProbeTimeoutMsecs = 2000; + +/** + * The options offered by the "Content Type" filter on the "Translate Text" and + * "Review Translations" tabs. + */ +export enum CONTENT_TYPE_FILTER { + ALL = 'All', + LESSONS = 'Lessons', + SKILLS = 'Skills', +} + export class Contributor extends ExplorationEditor { /** * Checks if the active tab name is visible and matches the expected values. @@ -482,9 +502,19 @@ export class Contributor extends ExplorationEditor { throw new Error(`Tab ${tabName} not found.`); } - // Click on the tab. - await this.waitForElementToBeClickable(tabElement); - await tabElement.click(); + // The tab is a list item and its click handler sits on the link inside it, + // so the link is what has to be clicked. + const tabLink = await tabElement.$('a'); + if (!tabLink) { + throw new Error(`Tab ${tabName} has no link to click.`); + } + + // The click is dispatched on the link rather than made natively because + // Puppeteer re-centres a target before dispatching a mouse event, which at + // mobile width parks the tab under the sticky navigation bar and delivers + // the click to the bar instead. + await this.waitForElementToStabilize(tabLink); + await tabLink.evaluate(el => (el as HTMLElement).click()); // Verify tab is active. if (tabName !== 'My Contributions') { @@ -701,6 +731,132 @@ export class Contributor extends ExplorationEditor { async expectQuestionInReviewModalToBe(question: string): Promise { await this.expectTextContentToBe(rteDisplaySelector, question); } + + /** + * Selects an option in the "Content Type" filter. + * @param contentType - The content type to filter the opportunity list by. + */ + async selectContentTypeFilter( + contentType: CONTENT_TYPE_FILTER + ): Promise { + await this.openContentTypeFilterDropdown(); + + await this.expectElementToBeVisible(entityTypeOptionSelector); + let optionElement: ElementHandle | null = null; + for (const option of await this.page.$$(entityTypeOptionSelector)) { + const optionText = await option.evaluate(el => el.textContent?.trim()); + if (optionText === contentType) { + optionElement = option; + break; + } + } + + if (!optionElement) { + throw new Error(`Content type option ${contentType} not found.`); + } + + // The option is dispatched on the element for the same reason the toggle + // is. A native click re-centres the option first, which at mobile width + // puts it under the sticky navigation bar, and the click then lands on the + // bar instead. The dropdown closes on that outside click without the + // option ever being selected. + await optionElement.evaluate(el => (el as HTMLElement).click()); + + // Verify the option is selected. + await this.expectTextContentToBe(selectedEntityTypeSelector, contentType); + } + + /** + * Opens the "Content Type" dropdown, unless it is already open. The control + * toggles, so clicking it when the options are already showing would close + * them again. + */ + private async openContentTypeFilterDropdown(): Promise { + await this.expectElementToBeVisible(selectedEntityTypeSelector); + const dropdownIsOpen = await this.isElementVisible( + entityTypeOptionSelector, + true, + dropdownStateProbeTimeoutMsecs + ); + if (dropdownIsOpen) { + return; + } + await this.clickContentTypeFilterToggle(); + } + + /** + * Clicks the control that opens and closes the "Content Type" dropdown. + */ + private async clickContentTypeFilterToggle(): Promise { + // At mobile width the filter row sits under the sticky navigation bar. + // Puppeteer scrolls a target back to the centre of the viewport before it + // dispatches a mouse event, which parks the control under that bar and + // delivers the click to the bar instead, so the click is dispatched on the + // control itself where it cannot be intercepted. + const toggle = await this.page.waitForSelector(selectedEntityTypeSelector); + if (!toggle) { + throw new Error('The content type filter was not found.'); + } + await this.waitForElementToStabilize(toggle); + await toggle.evaluate(el => (el as HTMLElement).click()); + } + + /** + * Checks that the "Content Type" filter offers exactly the expected options. + * The dropdown is closed again afterwards, so that the filter is left as it + * was found. + * @param expectedOptions - The options the dropdown should offer, in order. + */ + async expectContentTypeFilterOptionsToBe( + expectedOptions: CONTENT_TYPE_FILTER[] + ): Promise { + await this.openContentTypeFilterDropdown(); + + await this.expectElementToBeVisible(entityTypeOptionSelector); + const options = await this.page.$$eval(entityTypeOptionSelector, elements => + elements.map(element => element.textContent?.trim()) + ); + + if (options.length !== expectedOptions.length) { + throw new Error( + `Expected the content type filter to offer ${expectedOptions.length} ` + + `options, but it offered ${options.length}: ${options}.` + ); + } + for (let i = 0; i < expectedOptions.length; i++) { + if (options[i] !== expectedOptions[i]) { + throw new Error( + `Expected content type option ${i} to be "${expectedOptions[i]}", ` + + `but it was "${options[i]}".` + ); + } + } + await this.clickContentTypeFilterToggle(); + await this.expectElementToBeVisible(entityTypeOptionSelector, false); + showMessage( + `Success: The content type filter offers exactly ${expectedOptions}.` + ); + } + + /** + * Checks whether the "Content Type" filter is shown on the active tab. + * @param visible - Whether the filter should be shown. + */ + async expectContentTypeFilterToBeVisible( + visible: boolean = true + ): Promise { + await this.expectElementToBeVisible(entityTypeSelector, visible); + } + + /** + * Checks that the "Content Type" filter currently shows the given option. + * @param contentType - The option the filter is expected to show. + */ + async expectSelectedContentTypeFilterToBe( + contentType: CONTENT_TYPE_FILTER + ): Promise { + await this.expectTextContentToBe(selectedEntityTypeSelector, contentType); + } } export const ContributorFactory = (): Contributor => new Contributor(); diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts index b6540a7f384cd..b7ab226c6dc7d 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts @@ -323,6 +323,7 @@ const languageFilterDropdownToggler = '.oppia-search-bar-dropdown-toggle-button'; const lessonCardTitleSelector = '.e2e-test-exploration-tile-title'; const explorationTitleSelector = '.e2e-test-exp-summary-tile-title'; +const explorationObjectiveSelector = '.e2e-test-exp-summary-tile-objective'; const explorationRatingSelector = '.e2e-test-exp-summary-tile-rating'; const desktopStoryTitleSelector = '.e2e-test-story-title-in-topic-page'; const mobileStoryTitleSelector = '.e2e-test-mobile-story-title'; @@ -8358,6 +8359,60 @@ export class LoggedOutUser extends BaseUser { throw new Error(`Expected ${url} to return HTTP 200 but got ${status}`); } } + + /** + * Checks the title and objective shown on a lesson tile in the community + * library. The objective is only rendered at desktop width, so it is checked + * only there. + * @param title - The title the tile is expected to show. + * @param objective - The objective the tile is expected to show. + */ + async expectLessonTileToShow( + title: string, + objective: string + ): Promise { + await this.page.waitForSelector(lessonCardSelector); + const tiles = await this.page.$$(lessonCardSelector); + + const titlesFound: (string | undefined)[] = []; + for (const tile of tiles) { + const tileTitle = await tile.evaluate( + (el: Element, sel: string) => + el.querySelector(sel)?.textContent?.trim(), + explorationTitleSelector + ); + titlesFound.push(tileTitle); + if (tileTitle !== title) { + continue; + } + if (this.isViewportAtMobileWidth()) { + showMessage(`Success: The lesson tile shows the title "${title}".`); + return; + } + const tileObjective = await tile.evaluate( + (el: Element, sel: string) => + el.querySelector(sel)?.textContent?.trim(), + explorationObjectiveSelector + ); + // The tile truncates the objective at 95 characters, so the rendered + // text is checked for the expected objective rather than against it. + if (!tileObjective?.includes(objective)) { + throw new Error( + `The lesson tile titled "${title}" shows the objective ` + + `"${tileObjective}", but "${objective}" was expected.` + ); + } + showMessage( + `Success: The lesson tile shows the title "${title}" and the ` + + `objective "${objective}".` + ); + return; + } + + throw new Error( + `The lesson tile titled "${title}" was not found. Tiles found: ${titlesFound.join(', ')}` + ); + } } export let LoggedOutUserFactory = (): LoggedOutUser => new LoggedOutUser(); diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/translation-reviewer.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/translation-reviewer.ts index 485da18498280..16a2b35997631 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/user/translation-reviewer.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/user/translation-reviewer.ts @@ -18,6 +18,7 @@ import {ElementHandle} from 'puppeteer'; import {BaseUser} from '../common/puppeteer-utils'; +import {showMessage} from '../common/show-message'; const opportunityItemSelector = '.e2e-test-opportunity-list-item'; const opportunityItemHeadingSelector = @@ -39,6 +40,18 @@ const translatedContentContainerSelector = '.e2e-test-translated-content'; const reviewModalContainerSelector = '.e2e-test-translation-review-modal'; const updateTranslationBtnSelector = '.e2e-test-update-translation-button'; +// How long to wait when checking whether the review modal is still open after +// a suggestion has been resolved. The modal closes without a request of its +// own, so this only has to cover a render. +const reviewModalProbeTimeoutMsecs = 2000; + +// The suggestion list reloads itself shortly after it first renders, which +// detaches the rows a search was holding. The search is therefore retried, and +// three attempts spaced by the delay below comfortably outlast that reload +// while still failing quickly when the row genuinely is not there. +const opportunityCardSearchAttempts = 3; +const opportunityCardSearchRetryDelayMsecs = 1000; + export class TranslationReviewer extends BaseUser { /** * Clicks on the translate button in the translation modal. @@ -66,7 +79,7 @@ export class TranslationReviewer extends BaseUser { `Translate button for chapter ${chapterName} and story ${storyName} not found.` ); } - await translateButton.click(); + await translateButton.evaluate(el => (el as HTMLElement).click()); // Verify that the translation editor is opened. const backToLessonButtonVisible = await this.isElementVisible( @@ -96,29 +109,48 @@ export class TranslationReviewer extends BaseUser { heading: string, subheading: string ): Promise> { - await this.expectElementToBeVisible(opportunityItemSelector); - - const opportunityItems = await this.page.$$(opportunityItemSelector); let opportunityItem: ElementHandle | null = null; - for (const opportunityItemElement of opportunityItems) { - const opportunityItemHeading = await opportunityItemElement.evaluate( - (el: Element, sel: string) => - el.querySelector(sel)?.textContent?.trim(), - opportunityItemHeadingSelector - ); - const opportunityItemSubHeading = await opportunityItemElement.evaluate( - (el: Element, sel: string) => - el.querySelector(sel)?.textContent?.trim(), - opportunitySubHeadingSelector - ); - if ( - opportunityItemHeading === heading && - opportunityItemSubHeading?.includes(subheading) - ) { - opportunityItem = opportunityItemElement; - break; + for (let attempt = 1; attempt <= opportunityCardSearchAttempts; attempt++) { + try { + await this.expectElementToBeVisible(opportunityItemSelector); + const opportunityItems = await this.page.$$(opportunityItemSelector); + + for (const opportunityItemElement of opportunityItems) { + const opportunityItemHeading = await opportunityItemElement.evaluate( + (el: Element, sel: string) => + el.querySelector(sel)?.textContent?.trim(), + opportunityItemHeadingSelector + ); + const opportunityItemSubHeading = + await opportunityItemElement.evaluate( + (el: Element, sel: string) => + el.querySelector(sel)?.textContent?.trim(), + opportunitySubHeadingSelector + ); + + if ( + opportunityItemHeading === heading && + opportunityItemSubHeading?.includes(subheading) + ) { + opportunityItem = opportunityItemElement; + break; + } + } + if (opportunityItem) { + break; + } + } catch (error) { + // A row that the reload detached mid-search is retried rather than + // reported, since the list is about to render the same row again. + // Puppeteer only signals this in the message of the error it throws. + if (error instanceof Error && error.message.includes('detached')) { + continue; + } + throw error; } + + await this.page.waitForTimeout(opportunityCardSearchRetryDelayMsecs); } if (!opportunityItem) { @@ -143,10 +175,14 @@ export class TranslationReviewer extends BaseUser { subheading ); + // Puppeteer scrolls a target back to the centre of the viewport before + // it dispatches a mouse event, which parks the button underneath the + // sticky header and delivers the click to the header instead. + // By evaluating the click, it is dispatched on the element itself where + // it cannot be intercepted. if (this.isViewportAtMobileWidth()) { - await opportunityItem.click(); + await opportunityItem.evaluate(el => (el as HTMLElement).click()); } else { - // Click on translate button in the opportunity item. const translateButton = await opportunityItem.waitForSelector( opportunityTranslateButtonSelector ); @@ -155,12 +191,75 @@ export class TranslationReviewer extends BaseUser { `Translate button for chapter ${chapterName} and story ${subheading} not found.` ); } - await translateButton.click(); + await translateButton.evaluate(el => (el as HTMLElement).click()); + } + + await this.expectModalTitleToBe('Review Translation Contributions'); + } + + /** + * Opens the first suggestion in the list for review. The modal decides its + * button labels, and whether it closes or walks to the next suggestion, from + * the position of the row that was opened, so opening the first row keeps + * that behaviour the same no matter how the list happens to be sorted. + */ + async openFirstSuggestionForReview(): Promise { + await this.expectElementToBeVisible(opportunityItemSelector); + const [firstSuggestion] = await this.page.$$(opportunityItemSelector); + if (!firstSuggestion) { + throw new Error('There are no suggestions to review.'); + } + + if (this.isViewportAtMobileWidth()) { + // At mobile width the whole row is the button, and the click is + // dispatched on it so the sticky navigation bar cannot intercept it. + await firstSuggestion.evaluate(el => (el as HTMLElement).click()); + } else { + const reviewButton = await firstSuggestion.waitForSelector( + opportunityTranslateButtonSelector + ); + if (!reviewButton) { + throw new Error('The review button on the first suggestion is absent.'); + } + await reviewButton.evaluate(el => (el as HTMLElement).click()); } await this.expectModalTitleToBe('Review Translation Contributions'); } + /** + * Opens the first suggestion in the list and accepts every suggestion the + * modal then walks through. Opening the first row means the modal holds all + * of the suggestions, so one pass resolves the whole list without reading it + * again. + * @param expectedToastMessage - The toast each accept raises. + * @param maxSuggestions - Safety bound so a modal that stops closing fails + * instead of looping forever. + */ + async acceptAllSuggestionsInReviewModal( + expectedToastMessage: string, + maxSuggestions: number + ): Promise { + await this.openFirstSuggestionForReview(); + + for (let accepted = 1; accepted <= maxSuggestions; accepted++) { + const modalIsStillOpen = await this.reviewActiveSuggestion( + 'accept', + undefined, + expectedToastMessage + ); + if (!modalIsStillOpen) { + showMessage(`Accepted ${accepted} suggestions.`); + return; + } + } + + throw new Error( + `The review modal was still open after accepting ${maxSuggestions} ` + + 'suggestions.' + ); + } + /** * Checks if the pin icon is visible in the review page. */ @@ -177,32 +276,7 @@ export class TranslationReviewer extends BaseUser { reviewType: 'accept' | 'reject', reviewMessage?: string ): Promise { - const buttonSelector = - reviewType === 'accept' - ? acceptTranslationButtonSelector - : rejectTranslationButtonSelector; - if (reviewMessage) { - await this.expectElementToBeVisible(reviewCommentInputSelector); - await this.typeInInputField(reviewCommentInputSelector, reviewMessage); - } - - await this.expectElementToBeVisible(reviewContentContainerSelector); - const initialReviewContent = await this.page.$eval( - reviewContentContainerSelector, - el => el.textContent - ); - - await this.clickOnElementWithSelector(buttonSelector); - - await this.page.waitForFunction( - (selector: string, initialContent: string) => { - const element = document.querySelector(selector); - return element?.textContent !== initialContent; - }, - {}, - reviewContentContainerSelector, - initialReviewContent - ); + await this.reviewActiveSuggestion(reviewType, reviewMessage); } /** @@ -244,6 +318,169 @@ export class TranslationReviewer extends BaseUser { await this.clickOnElementWithSelector(updateTranslationBtnSelector); await this.expectElementToBeVisible(updateTranslationBtnSelector, false); } + + /** + * Checks the label on an opportunity card's action button. + * @param heading - The heading of the opportunity card. + * @param subheading - The subheading of the opportunity card. + * @param label - The label the action button is expected to carry. + */ + async expectOpportunityActionButtonToBe( + heading: string, + subheading: string, + label: string + ): Promise { + const opportunityItem = await this.getTranslationOpportunityCard( + heading, + subheading + ); + const buttonText = await opportunityItem.evaluate( + (el: Element, sel: string) => el.querySelector(sel)?.textContent?.trim(), + opportunityTranslateButtonSelector + ); + if (buttonText !== label) { + throw new Error( + `Expected the action button on "${heading}" to read "${label}", but ` + + `it read "${buttonText}".` + ); + } + } + + /** + * Checks whether the "Back to lessons" control is shown. It sits above the + * suggestion list on the lesson path only, so its absence is what + * distinguishes the skills path, where the suggestions are listed without an + * opportunity card to click through first. + * @param visible - Whether the control should be shown. + */ + async expectBackToLessonsControlToBeVisible( + visible: boolean = true + ): Promise { + await this.expectElementToBeVisible(backToLessonButtonSelector, visible); + } + + /** + * Submits a review and checks the toast it raises. The toast is short-lived, + * so it is checked immediately after the click rather than after waiting for + * the next suggestion to load. + * @param reviewType - Whether to accept or reject the suggestion. + * @param expectedToastMessage - The toast the review is expected to raise. + * @param reviewMessage - The comment to leave, required to reject. + */ + async submitTranslationReviewAndExpectToast( + reviewType: 'accept' | 'reject', + expectedToastMessage: string, + reviewMessage?: string + ): Promise { + await this.reviewActiveSuggestion( + reviewType, + reviewMessage, + expectedToastMessage + ); + } + + /** + * Reviews the suggestion the modal is showing, then waits for the modal to + * either move on to the next suggestion or close. + * @param reviewType - The type of the review to submit. + * @param reviewMessage - The message to add to the review, if any. + * @param expectedToastMessage - The toast the review is expected to raise. + * A review is sent as soon as its button is pressed and its toast clears + * a few seconds later, so it is read here rather than by the caller. + * @returns Whether the modal is still open on a following suggestion. + */ + private async reviewActiveSuggestion( + reviewType: 'accept' | 'reject', + reviewMessage?: string, + expectedToastMessage?: string + ): Promise { + const buttonSelector = + reviewType === 'accept' + ? acceptTranslationButtonSelector + : rejectTranslationButtonSelector; + if (reviewMessage) { + await this.expectElementToBeVisible(reviewCommentInputSelector); + await this.typeInInputField(reviewCommentInputSelector, reviewMessage); + } + + await this.expectElementToBeVisible(reviewContentContainerSelector); + const contentBeforeReview = await this.page.$eval( + reviewContentContainerSelector, + el => el.textContent + ); + + await this.pressReviewButton(buttonSelector); + if (expectedToastMessage) { + await this.expectToastMessage(expectedToastMessage); + } + + const modalIsStillOpen = await this.isElementVisible( + reviewModalContainerSelector, + true, + reviewModalProbeTimeoutMsecs + ); + if (!modalIsStillOpen) { + return false; + } + + // The modal stays on the suggestion just reviewed until the next one has + // loaded, and its review buttons stay disabled for that whole time, so + // whatever follows waits for the content to change first. + await this.page.waitForFunction( + (selector: string, previousContent: string) => { + const element = document.querySelector(selector); + return element === null || element.textContent !== previousContent; + }, + {}, + reviewContentContainerSelector, + contentBeforeReview ?? '' + ); + return true; + } + + /** + * Presses an accept or reject button in the review modal. The button is + * disabled while the previous review is still resolving, so this waits for + * it to come back, and dispatches the click on the element so that neither + * a toast nor the navigation bar can intercept it. + * @param buttonSelector - The review button to press. + */ + private async pressReviewButton(buttonSelector: string): Promise { + await this.page.waitForFunction( + (selector: string) => { + const button = document.querySelector( + selector + ) as HTMLButtonElement | null; + return button !== null && !button.disabled; + }, + {}, + buttonSelector + ); + + const button = await this.page.waitForSelector(buttonSelector); + if (!button) { + throw new Error(`The review button ${buttonSelector} was not found.`); + } + await button.evaluate(el => (el as HTMLElement).click()); + } + + /** + * Checks the label on the accept or reject button, which names the next + * suggestion while more remain and drops that mention on the last one. + * @param reviewType - Whether to check the accept or the reject button. + * @param label - The label the button is expected to carry. + */ + async expectReviewButtonLabelToBe( + reviewType: 'accept' | 'reject', + label: string + ): Promise { + await this.expectTextContentToBe( + reviewType === 'accept' + ? acceptTranslationButtonSelector + : rejectTranslationButtonSelector, + label + ); + } } export let TranslationReviewerFactory = (): TranslationReviewer => diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/translation-submitter.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/translation-submitter.ts index a1456e2b36ec9..e330e539ad1a2 100644 --- a/core/tests/puppeteer-acceptance-tests/utilities/user/translation-submitter.ts +++ b/core/tests/puppeteer-acceptance-tests/utilities/user/translation-submitter.ts @@ -59,6 +59,20 @@ const discardChangeButton = '.e2e-test-discard-translation-chages'; const currentProgressSelector = '.e2e-test-opportunity-list-item-progress-percentage'; +// The instruction line naming the content type being translated, the block +// listing the validation errors for the current translation, and the single +// save button whose label depends on whether more items remain. None of the +// three carries an e2e class, so they are matched on their layout classes. +const translationInstructionSelector = + '.oppia-translatable-text-section strong'; +const translationErrorSectionSelector = '.oppia-translation-error-section'; +const saveTranslationButtonSelector = '.e2e-test-save-button'; + +// How long to wait when checking whether the current item still offers a Skip +// link. The link's presence is decided by the item already on screen rather +// than by a pending request, so this only has to cover a render. +const skipLinkProbeTimeoutMsecs = 2000; + // Number of times a translate button click is attempted before giving up, and // how long each attempt waits for the translation modal to open. The product of // the two is kept at the default 30 second selector timeout. @@ -152,6 +166,14 @@ export class TranslationSubmitter extends BaseUser { // the opportunity is re-queried and clicked again if the modal does not // open within the timeout. for (let attempt = 1; attempt <= translateButtonClickAttempts; attempt++) { + // The top navigation's menus open on mouseover and close only on + // mouseleave. Puppeteer's pointer is virtual and stays wherever the last + // native click left it, so one of those menus can still be hanging open + // over the opportunity list, which puts its own markup at the centre of + // the translate button and fails the clickability check below. Parking + // the pointer at the page origin fires the mouseleave that closes it. + await this.page.mouse.move(0, 0); + const opportunityItem = await this.expectTranslationOpportunityToBePresentInTranslateTextTab( chapterName, @@ -569,6 +591,166 @@ export class TranslationSubmitter extends BaseUser { skill ); } + + /** + * Checks the instruction line at the top of the translation modal, which + * names the content type of the item currently being translated. + * @param contentType - The content type as it is spelled out to the + * contributor, for example "title" or "skill description". + * @param languageDescription - The target language as it is shown in the + * language filter, for example "हिन्दी (Hindi)". + */ + async expectTranslationInstructionToBe( + contentType: string, + languageDescription: string + ): Promise { + await this.expectTextContentToBe( + translationInstructionSelector, + `Please translate the given ${contentType} in ${languageDescription}:` + ); + } + + /** + * Steps through the translation modal until the instruction line names the + * given content type, so that a test can reach a specific item without + * depending on the order the items are served in. + * @param contentType - The content type to stop on. + * @param maxSkips - How many items to step past before giving up. + */ + async skipToTranslationItemOfContentType( + contentType: string, + maxSkips: number + ): Promise { + const instructionsSeen: (string | undefined)[] = []; + for (let skips = 0; skips <= maxSkips; skips++) { + // The source block is removed while an item is loading and while a + // translation is uploading, so waiting for it back settles the modal + // before the instruction is read. Without this the read can land on the + // item that was just saved or skipped, and the skip below would then + // consume the item the caller was looking for. + await this.expectElementToBeVisible(textToTranslateContainerSelector); + await this.expectElementToBeVisible(translationInstructionSelector); + const instruction = await this.page.$eval( + translationInstructionSelector, + el => el.textContent?.trim() + ); + if ( + instruction?.startsWith(`Please translate the given ${contentType} `) + ) { + showMessage(`Reached the ${contentType} item after ${skips} skips.`); + return; + } + instructionsSeen.push(instruction); + + // The last item carries no Skip link, so running out of items is + // reported as such rather than as a selector timeout. + const skipLinkIsShown = await this.isElementVisible( + skipTranslationButtonSelector, + true, + skipLinkProbeTimeoutMsecs + ); + if (!skipLinkIsShown) { + throw new Error( + `Ran out of items before reaching a ${contentType} item. ` + + `Items seen: ${instructionsSeen.join(' | ')}.` + ); + } + await this.clickOnSkipTranslationButton(); + } + throw new Error( + `No ${contentType} item was reached within ${maxSkips} skips. ` + + `Items seen: ${instructionsSeen.join(' | ')}.` + ); + } + + /** + * Saves the current translation and waits for the modal to load the next + * item. Clicking save alone is not enough to move on, because the upload and + * the fetch that follows it are asynchronous, so a caller that read the + * modal straight afterwards would still see the item it had just saved. + * @param expectedToastMessage - The toast the save is expected to raise, if + * it is being checked. The toast clears after a few seconds, so it is + * checked before waiting for the next item rather than after. + */ + async saveTranslationAndMoveToNextItem( + expectedToastMessage?: string + ): Promise { + await this.expectElementToBeVisible(textToTranslateContainerSelector); + const contentBeforeSave = await this.page.$eval( + textToTranslateContainerSelector, + el => el.textContent + ); + + await this.expectElementToBeVisible(saveTranslationButtonSelector); + await this.clickOnElementWithSelector(saveTranslationButtonSelector); + + if (expectedToastMessage) { + await this.expectToastMessage(expectedToastMessage); + } + + // The source block is taken out of the DOM while the translation uploads, + // so requiring it back with different text proves the next item has + // arrived rather than the saved one still being on screen. + await this.page.waitForFunction( + (selector: string, previousContent: string) => { + const element = document.querySelector(selector); + return element !== null && element.textContent !== previousContent; + }, + {}, + textToTranslateContainerSelector, + contentBeforeSave ?? '' + ); + } + + /** + * Checks the source text shown for the item currently being translated. + * @param text - The expected source text. + */ + async expectTextToTranslateToBe(text: string): Promise { + await this.expectTextContentToBe(textToTranslateContainerSelector, text); + } + + /** + * Checks that the translation modal is reporting the given validation error. + * @param message - The expected error message. + */ + async expectTranslationErrorToBe(message: string): Promise { + await this.expectTextContentToContain( + translationErrorSectionSelector, + message + ); + } + + /** + * Checks that the translation modal is reporting no validation error. The + * error block is always in the DOM, so it is its text that is checked rather + * than its presence. + */ + async expectNoTranslationErrors(): Promise { + await this.page.waitForFunction( + (selector: string) => { + const element = document.querySelector(selector); + return element !== null && element.textContent?.trim() === ''; + }, + {}, + translationErrorSectionSelector + ); + showMessage('Success: The translation modal reports no errors.'); + } + + /** + * Checks whether the save button in the translation modal can be clicked. + * @param enabled - Whether the button should be enabled. + */ + async expectSaveTranslationButtonToBeEnabled( + enabled: boolean = true + ): Promise { + await this.expectElementToBeVisible(saveTranslationButtonSelector); + await this.expectElementToBeClickable( + saveTranslationButtonSelector, + enabled + ); + } } export let TranslationSubmitterFactory = (): TranslationSubmitter => diff --git a/core/tests/puppeteer/lighthouse_setup.js b/core/tests/puppeteer/lighthouse_setup.js index 97bf8c557f916..7c130797f132e 100644 --- a/core/tests/puppeteer/lighthouse_setup.js +++ b/core/tests/puppeteer/lighthouse_setup.js @@ -16,7 +16,6 @@ * @fileoverview Puppeteer script to collects dynamic urls for lighthouse tests. */ -var FirebaseAdmin = require('firebase-admin'); const process = require('process'); const puppeteer = require('puppeteer'); const {PuppeteerScreenRecorder} = require('puppeteer-screen-recorder'); @@ -565,8 +564,6 @@ const addThumbnailToTopic = async function (page, topicName) { }; const main = async function () { - process.env.FIREBASE_AUTH_EMULATOR_HOST = 'firebase:9099'; - FirebaseAdmin.initializeApp({projectId: 'dev-project-id'}); // Change headless to false to see the puppeteer actions. const browser = await puppeteer.launch({ headless: true, diff --git a/core/tests/root-files-config.json b/core/tests/root-files-config.json index 5362276ffbda5..41f9c04f73ffd 100644 --- a/core/tests/root-files-config.json +++ b/core/tests/root-files-config.json @@ -38,7 +38,6 @@ "core/templates/pages/blog-home-page/blog-home-page.component.css", "core/templates/pages/blog-post-page/blog-post-page.component.css", "core/tests/protractor-browserstack.conf.js", - "core/tests/wdio.conf.js", "data/README.md", "data/voiceovers/README.md", "extensions/README.md", diff --git a/core/tests/test-dependencies/root-files-mapping-generator.ts b/core/tests/test-dependencies/root-files-mapping-generator.ts index 484c3535b2a26..247db0ef98609 100644 --- a/core/tests/test-dependencies/root-files-mapping-generator.ts +++ b/core/tests/test-dependencies/root-files-mapping-generator.ts @@ -80,7 +80,6 @@ const FILE_EXCLUSIONS_FOR_SEARCH = [ 'core/tests/test-dependencies', 'core/templates/tests', 'core/templates/utility/hashes.ts', - 'angular-template-style-url-replacer.webpack-loader.js', 'core/tests/playwright-acceptance-tests', ]; @@ -113,8 +112,9 @@ const MANUALLY_MAPPED_DEPENDENCIES: Record = { }; const LIGHTHOUSE_MODULES = [ - '.lighthouserc-performance.js', - '.lighthouserc-accessibility.js', + '.lighthouserc.js', + '.lighthouserc-base.js', + '.lighthouserc-desktop.js', ]; const CI_TEST_SUITE_CONFIGS_DIRECTORY = path.resolve( @@ -475,8 +475,8 @@ const getDependenciesFromTypeScriptOrJavaScriptFile = ( }); // If the file ends with '.import.ts', we check if there is a mainpage file - // that corresponds to it and add it as a dependency since Webpack loads - // these. + // that corresponds to it and add it as a dependency since these are loaded + // as part of the build. if (file.endsWith('.import.ts')) { const mainPageFilePath = file.replace('.import.ts', '.mainpage.html'); if (fs.existsSync(path.join(ROOT_DIRECTORY, mainPageFilePath))) { diff --git a/core/tests/test_utils.py b/core/tests/test_utils.py index 656cd36d22edb..64e0b5fb9d2fe 100644 --- a/core/tests/test_utils.py +++ b/core/tests/test_utils.py @@ -272,11 +272,11 @@ def mock_load_template( filename: str, template_is_aot_compiled: bool = False ) -> str: """Mock for load_template function. This mock is required for backend tests - since we do not have webpack compilation before backend tests. The folder to - search templates is webpack_bundles which is generated after webpack - compilation. Since this folder will be missing, load_template function will - return an error. So, we use a mock for load_template which returns the html - file from the source directory instead. + since the compiled output may not be available. The compiled output would + normally be served from the build directory. Since this folder will be + missing during backend tests, load_template function will return an error. + So, we use a mock for load_template which returns the html file from the + source directory instead. Args: filename: str. The name of the file for which template is to be @@ -3036,9 +3036,8 @@ def _get_response( expect_errors = expected_status_int >= 400 # This swap is required to ensure that the templates are fetched from - # source directory instead of webpack_bundles since webpack_bundles is - # only produced after webpack compilation which is not performed during - # backend tests. + # source directory instead of the compiled build output since the build + # output is not available during backend tests. with self.swap(base, 'load_template', mock_load_template): response = self.testapp.get( url, @@ -3154,9 +3153,8 @@ def get_response_without_checking_for_errors( response = None # This swap is required to ensure that the templates are fetched from - # source directory instead of webpack_bundles since webpack_bundles is - # only produced after webpack compilation which is not performed during - # backend tests. + # source directory instead of the compiled build output since the build + # output is not available during backend tests. with self.swap(base, 'load_template', mock_load_template): if http_method == 'GET': diff --git a/core/tests/wdio.conf.js b/core/tests/wdio.conf.js deleted file mode 100644 index e4734b1925c82..0000000000000 --- a/core/tests/wdio.conf.js +++ /dev/null @@ -1,284 +0,0 @@ -require('dotenv').config(); -var FirebaseAdmin = require('firebase-admin'); -var path = require('path'); -var fs = require('fs'); -var childProcess = require('child_process'); -var Constants = require('./webdriverio_utils/WebdriverioConstants'); -var DOWNLOAD_PATH = path.resolve(__dirname, Constants.DOWNLOAD_PATH); -var args = process.argv; - -// When tests is running in debug mode, the chrome version number -// is passed as 7th argument else it is passed as 6th argument. -// eslint-disable-next-line eqeqeq -var chromeVersion = (args[0] == 'DEBUG=true') ? args[6] : args[5]; - -const drivers = { - chrome: { version: chromeVersion }, -}; - -// If video recorder is not running the ffmpeg process will be null. -var ffmpegProcess = null; -// The absolute path where the recorded video of test will be stored. -var videoPath = null; -// Enable ALL_VIDEOS if you want success videos to be saved. -const ALL_VIDEOS = false; - -var suites = { - full: [ - './core/tests/webdriverio_desktop/**/*.js', - ], - - explorationHistoryTab: [ - './core/tests/webdriverio_desktop/explorationHistoryTab.js' - ], - - skillEditor: [ - './core/tests/webdriverio_desktop/skillEditor.js' - ], - - topicAndStoryEditor: [ - './core/tests/webdriverio_desktop/topicAndStoryEditor.js' - ] -}; - -// A reference configuration file. -exports.config = { - // - // ==================== - // Runner Configuration - // ==================== - // - // - // ================== - // Specify Test Files - // ================== - // When run without a command line parameter, all suites will run. If run - // with --suite=smoke, only the patterns matched by that suite will run. - suites: suites, - // - // ============ - // Capabilities - // ============ - // Define your capabilities here. WebdriverIO can run multiple capabilities - // at the same time. Depending on the number of capabilities, WebdriverIO - // launches several test sessions. Within your capabilities you can overwrite - // the spec and exclude options in order to group specific specs to a - // specific capability. - capabilities: [{ - browserName: 'chrome', - 'goog:loggingPrefs': { - browser: 'ALL', - }, - 'goog:chromeOptions': { - args: [ - '--lang=en-EN', - '--window-size=1285x1000', - // These arguments let us simulate recording from a microphone. - '--use-fake-device-for-media-stream', - '--use-fake-ui-for-media-stream', - '--use-file-for-fake-audio-capture=data/cafe.mp3', - // These arguments are required to run the tests on GitHub - // Actions. - '--no-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu', - ], - prefs: { - download: { - // eslint-disable-next-line quote-props - 'prompt_for_download': false, - // eslint-disable-next-line quote-props - 'default_directory': DOWNLOAD_PATH, - } - } - } - }], - - // =================== - // Test Configurations - // =================== - // Define all options that are relevant for the WebdriverIO instance here - // - // Level of logging verbosity: trace | debug | info | warn | error | silent. - logLevel: 'warn', - - // Set a base URL in order to shorten url command calls. If your `url` - // parameter starts with `/`, the base url gets prepended, not including - // the path portion of your baseUrl. If your `url` parameter starts - // without a scheme or `/` (like `some/path`), the base url gets - // prepended directly. - baseUrl: 'http://localhost:8181', - - // Default timeout in milliseconds for request - // if browser driver or grid doesn't send response. - connectionRetryTimeout: 120000, - - // Default request retries count. - connectionRetryCount: 3, - - // Test runner services - // Services take over a specific job you don't want to take care of. - // They enhance your test setup with almost no effort. Unlike plugins, - // they don't add newcommands. Instead, they hook themselves up into - // the test process. - services: [ - ['selenium-standalone', { - logPath: 'logs', - installArgs: { drivers }, - args: { drivers } - }], - 'intercept' - ], - - // Framework you want to run your specs with. - // The following are supported: Mocha, Jasmine, and Cucumber - // see also: https://webdriver.io/docs/frameworks - // - // Make sure you have the wdio adapter package for the specific - // framework installed before running any tests. - framework: 'jasmine', - - // Test reporter for stdout. - // The only one supported by default is 'dot' - // see also: https://webdriver.io/docs/dot-reporter - reporters: [ - ['spec', { - showPreface: false, - realtimeReporting: true, - }] - ], - - isMobile: false, - - // Options to be passed to Jasmine. - jasmineOpts: { - // Default time to wait in ms before a test fails. - defaultTimeoutInterval: 1200000, - // Option to stop execution of the suite after the first spec failure. - stopOnSpecFailure: true, - }, - - - // ===== - // Hooks - // ===== - // WebdriverIO provides several hooks you can use to interfere with - // the test process in order to enhance it and to build services around it. - // You can either apply a single function or an array of methods to it. - // If one of them returns with a promise, WebdriverIO will wait until that - // promise got resolved to continue. - /** - * Gets executed before test execution begins. At this point you can access - * to all global variables like `browser`. It is the perfect place to - * define custom commands. - * @param {Array.} capabilities list of capabilities details - * @param {Array.} specs List of spec file paths that are to be run - * @param {Object} browser instance of created browser/device session - */ - before: async function() { - // eslint-disable-next-line eqeqeq - mobileViewportArg = process.env.MOBILE == 'true'; - - // eslint-disable-next-line eqeqeq - if (mobileViewportArg) { - await browser.setWindowSize(600, 1000); - } else { - // Set a wide enough window size for the navbar in the library pages to - // display fully. - await browser.setWindowSize(1285, 1000); - } - - - // Configure the Firebase Admin SDK to communicate with the emulator. - process.env.FIREBASE_AUTH_EMULATOR_HOST = 'localhost:9099'; - FirebaseAdmin.initializeApp({projectId: 'dev-project-id'}); - }, - /** - * Function to be executed before a test (in Mocha/Jasmine only) - * @param {Object} test test object - * @param {Object} context scope object the test was executed with - */ - beforeTest: function(test, context) { - if (// eslint-disable-next-line eqeqeq - process.env.VIDEO_RECORDING_IS_ENABLED == 1) { - let ffmpegArgs = [ - '-y', - '-r', '30', - '-f', 'x11grab', - '-s', '1285x1000', - '-i', process.env.DISPLAY, - '-g', '300', - '-loglevel', '16', - // Use ultrafast preset to reduce overhead, - // which is crucial when testing in CI. - '-preset', 'ultrafast', - ]; - const uniqueString = Math.random().toString(36).substring(2, 8); - var name = uniqueString + '.mp4'; - var dirPath = path.resolve( - '__dirname', '..', '..', 'webdriverio-video/'); - try { - fs.mkdirSync(dirPath, { recursive: true }); - } catch (err) {} - videoPath = path.resolve(dirPath, name); - ffmpegArgs.push(videoPath); - ffmpegProcess = childProcess.spawn('ffmpeg', ffmpegArgs); - // eslint-disable-next-line no-console - console.log( - 'Test name: ' + test.fullName + ' has video path ' + videoPath); - ffmpegProcess.on('message', (message) => { - // eslint-disable-next-line no-console - console.log(`ffmpeg stdout: ${message}`); - }); - ffmpegProcess.on('error', (errorMessage) => { - console.error(`ffmpeg stderr: ${errorMessage}`); - }); - ffmpegProcess.on('close', (code) => { - // eslint-disable-next-line no-console - console.log(`ffmpeg exited with code ${code}`); - }); - } - }, - /** - * Function to be executed after a test - * @param {Object} test test object - * @param {Object} context scope object the test was executed with - * @param {Error} result.error error object in case the test fails, - * otherwise `undefined` - * @param {Any} result.result return object of test function - * @param {Number} result.duration duration of test - * @param {Boolean} result.passed true if test has passed, otherwise false - * @param {Object} result.retries informations to spec related retries, - * e.g. `{ attempts: 0, limit: 0 }` - */ - afterTest: async function( - test, context, { error, result, duration, passed, retries }) { - if (// eslint-disable-next-line eqeqeq - process.env.VIDEO_RECORDING_IS_ENABLED == 1) { - ffmpegProcess.kill(); - if (passed === true && !ALL_VIDEOS && fs.existsSync(videoPath)) { - fs.unlinkSync(videoPath); - // eslint-disable-next-line no-console - console.log( - `Video for test: ${test.fullName}` + - 'was deleted successfully (test passed).'); - } - } - // If a test fails then only the error will be defined and - // the screenshot will be taken and saved. - if (error) { - var dirPath = path.resolve( - '__dirname', '..', '..', 'webdriverio-screenshots/'); - try { - fs.mkdirSync(dirPath, { recursive: true }); - var screenshotPath = '../webdriverio-screenshots'; - } catch (err) {} - - var testName = encodeURIComponent(test.fullName.replace(/\s+/g, '-')); - var fileName = testName + '.png'; - var filePath = path.join(screenshotPath, fileName); - // Save screenshot. - await browser.saveScreenshot(filePath); - } - }, -}; diff --git a/core/tests/webdriverio_desktop/explorationHistoryTab.js b/core/tests/webdriverio_desktop/explorationHistoryTab.js deleted file mode 100644 index 6e3ef92bed6e1..0000000000000 --- a/core/tests/webdriverio_desktop/explorationHistoryTab.js +++ /dev/null @@ -1,731 +0,0 @@ -// Copyright 2022 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview End-to-end tests for the functionality the history tab of the - * exploration editor. - */ - -var forms = require('../webdriverio_utils/forms.js'); -var general = require('../webdriverio_utils/general.js'); -var users = require('../webdriverio_utils/users.js'); -var workflow = require('../webdriverio_utils/workflow.js'); - -var ExplorationEditorPage = require('../webdriverio_utils/ExplorationEditorPage.js'); -var ExplorationPlayerPage = require('../webdriverio_utils/ExplorationPlayerPage.js'); - -describe('Exploration history', function () { - var explorationEditorPage = null; - var explorationPlayerPage = null; - var explorationEditorHistoryTab = null; - var explorationEditorMainTab = null; - var explorationEditorSettingsTab = null; - - // Constants for colors of nodes in history graph. - var COLOR_ADDED = 'rgb(78,162,78)'; - var COLOR_DELETED = 'rgb(220,20,60)'; - var COLOR_CHANGED = 'rgb(30,144,255)'; - var COLOR_UNCHANGED = 'rgb(245,245,220)'; - var COLOR_RENAMED_UNCHANGED = 'rgb(255,215,0)'; - - beforeEach(function () { - explorationEditorPage = new ExplorationEditorPage.ExplorationEditorPage(); - explorationEditorHistoryTab = explorationEditorPage.getHistoryTab(); - explorationEditorMainTab = explorationEditorPage.getMainTab(); - explorationEditorSettingsTab = explorationEditorPage.getSettingsTab(); - explorationPlayerPage = new ExplorationPlayerPage.ExplorationPlayerPage(); - }); - - it('should correctly display the dates of the commits', async function () { - await users.createUser('userTestDate@historyTab.com', 'testDateUsername'); - await users.login('userTestDate@historyTab.com'); - - // Creating an exploration creates the first commit. Therefore, there - // should be a date associated with it. - await workflow.createExploration(true); - - // Switch to the history tab because that is where the commit history - // is displayed. - await explorationEditorPage.navigateToHistoryTab(); - await explorationEditorHistoryTab.expectCommitDatesToBeDisplayed(); - - await users.logout(); - }); - - it('should display the history of the states', async function () { - await users.createUser('user@historyTab.com', 'userHistoryTab'); - await users.login('user@historyTab.com'); - await workflow.createExploration(true); - - // Check renaming state, editing text, editing interactions and adding - // state. - await explorationEditorMainTab.setStateName('first'); - explorationEditorMainTab.setContent( - await forms.toRichText('enter 6 to continue'), - true - ); - await explorationEditorMainTab.setInteraction('NumericInput', false); - await explorationEditorMainTab.addResponse( - 'NumericInput', - null, - 'second', - true, - 'Equals', - 6 - ); - await explorationEditorMainTab.moveToState('second'); - await explorationEditorMainTab.setContent( - await forms.toRichText('this is card 2'), - true - ); - await explorationEditorMainTab.setInteraction('Continue'); - var responseEditor = - await explorationEditorMainTab.getResponseEditor('default'); - await responseEditor.setDestination('final card', true, null); - // Setup a terminating state. - await explorationEditorMainTab.moveToState('final card'); - await explorationEditorMainTab.setInteraction('EndExploration'); - await explorationEditorMainTab.moveToState('first'); - await explorationEditorPage.saveChanges(); - - var VERSION_1_STATE_1_CONTENTS = { - 1: { - text: 'content:', - highlighted: false, - }, - 2: { - text: " html: ''", - highlighted: true, - }, - 3: { - text: ' content_id: content_0', - highlighted: false, - }, - 4: { - text: 'classifier_model_id: null', - highlighted: false, - }, - 5: { - text: 'linked_skill_id: null', - highlighted: false, - }, - 6: { - text: 'interaction:', - highlighted: false, - }, - 7: { - text: ' answer_groups: []', - highlighted: true, - }, - 8: { - text: ' confirmed_unclassified_answers: []', - highlighted: true, - }, - 9: { - text: ' customization_args: {}', - highlighted: true, - }, - 10: { - text: ' default_outcome:', - highlighted: true, - }, - // Note that highlighting *underneath* a line is still considered a - // highlight. - 11: { - text: ' dest: ' + general.FIRST_STATE_DEFAULT_NAME, - highlighted: true, - }, - 12: { - text: ' dest_if_really_stuck: null', - highlighted: false, - }, - 13: { - text: ' feedback:', - highlighted: false, - }, - 14: { - text: " html: ''", - highlighted: false, - }, - 15: { - text: ' content_id: default_outcome_1', - highlighted: false, - }, - 16: { - text: ' labelled_as_correct: false', - highlighted: false, - }, - 17: { - text: ' param_changes: []', - highlighted: false, - }, - 18: { - text: ' refresher_exploration_id: null', - highlighted: false, - }, - 19: { - text: ' missing_prerequisite_skill_id: null', - highlighted: false, - }, - 20: { - text: ' hints: []', - highlighted: false, - }, - 21: { - text: ' id: null', - highlighted: true, - }, - 22: { - text: ' solution: null', - highlighted: false, - }, - 23: { - text: 'param_changes: []', - highlighted: false, - }, - 24: { - text: 'solicit_answer_details: false', - highlighted: false, - }, - 25: { - text: 'card_is_checkpoint: true', - highlighted: false, - }, - 26: { - text: 'inapplicable_skill_misconception_ids: []', - highlighted: false, - }, - 27: { - text: '', - highlighted: false, - }, - }; - - var VERSION_2_STATE_1_CONTENTS = { - 1: { - text: 'content:', - highlighted: false, - }, - 2: { - text: ' html:

    enter 6 to continue

    ', - highlighted: true, - }, - 3: { - text: ' content_id: content_0', - highlighted: false, - }, - 4: { - text: 'classifier_model_id: null', - highlighted: false, - }, - 5: { - text: 'linked_skill_id: null', - highlighted: false, - }, - 6: { - text: 'interaction:', - highlighted: false, - }, - 7: { - text: ' answer_groups:', - highlighted: true, - }, - 8: { - text: ' - rule_specs:', - highlighted: true, - }, - 9: { - text: ' - rule_type: Equals', - highlighted: true, - }, - 10: { - text: ' inputs:', - highlighted: true, - }, - 11: { - text: ' x: 6', - highlighted: true, - }, - 12: { - text: ' outcome:', - highlighted: true, - }, - 13: { - text: ' dest: second', - highlighted: true, - }, - 14: { - text: ' dest_if_really_stuck: null', - highlighted: true, - }, - 15: { - text: ' feedback:', - highlighted: true, - }, - 16: { - text: " html: ''", - highlighted: true, - }, - 17: { - text: ' content_id: feedback_2', - highlighted: true, - }, - 18: { - text: ' labelled_as_correct: false', - highlighted: true, - }, - 19: { - text: ' param_changes: []', - highlighted: true, - }, - 20: { - text: ' refresher_exploration_id: null', - highlighted: true, - }, - 21: { - text: ' missing_prerequisite_skill_id: null', - highlighted: true, - }, - 22: { - text: ' training_data: []', - highlighted: true, - }, - 23: { - text: ' tagged_skill_misconception_id: null', - highlighted: true, - }, - 24: { - text: ' confirmed_unclassified_answers: []', - highlighted: true, - }, - 25: { - text: ' customization_args:', - highlighted: true, - }, - 26: { - text: ' requireNonnegativeInput:', - highlighted: true, - }, - 27: { - text: ' value: false', - highlighted: true, - }, - 28: { - text: ' default_outcome:', - highlighted: true, - }, - 29: { - text: ' dest: first', - highlighted: true, - }, - 30: { - text: ' dest_if_really_stuck: null', - highlighted: false, - }, - 31: { - text: ' feedback:', - highlighted: false, - }, - 32: { - text: " html: ''", - highlighted: false, - }, - 33: { - text: ' content_id: default_outcome_1', - highlighted: false, - }, - 34: { - text: ' labelled_as_correct: false', - highlighted: false, - }, - 35: { - text: ' param_changes: []', - highlighted: false, - }, - 36: { - text: ' refresher_exploration_id: null', - highlighted: false, - }, - 37: { - text: ' missing_prerequisite_skill_id: null', - highlighted: false, - }, - 38: { - text: ' hints: []', - highlighted: false, - }, - 39: { - text: ' id: NumericInput', - highlighted: true, - }, - 40: { - text: ' solution: null', - highlighted: false, - }, - 41: { - text: 'param_changes: []', - highlighted: false, - }, - 42: { - text: 'solicit_answer_details: false', - highlighted: false, - }, - 43: { - text: 'card_is_checkpoint: true', - highlighted: false, - }, - 44: { - text: 'inapplicable_skill_misconception_ids: []', - highlighted: false, - }, - 45: { - text: '', - highlighted: false, - }, - }; - - var STATE_2_STRING = - 'content:\n' + - ' html:

    this is card 2

    \n' + - ' content_id: content_3\n' + - 'classifier_model_id: null\n' + - 'linked_skill_id: null\n' + - 'interaction:\n' + - ' answer_groups: []\n' + - ' confirmed_unclassified_answers: []\n' + - ' customization_args:\n' + - ' buttonText:\n' + - ' value:\n' + - ' unicode_str: Continue\n' + - ' content_id: ca_buttonText_5\n' + - ' default_outcome:\n' + - ' dest: final card\n' + - ' dest_if_really_stuck: null\n' + - ' feedback:\n' + - " html: ''\n" + - ' content_id: default_outcome_4\n' + - ' labelled_as_correct: false\n' + - ' param_changes: []\n' + - ' refresher_exploration_id: null\n' + - ' missing_prerequisite_skill_id: null\n' + - ' hints: []\n' + - ' id: Continue\n' + - ' solution: null\n' + - 'param_changes: []\n' + - 'solicit_answer_details: false\n' + - 'card_is_checkpoint: false\n' + - 'inapplicable_skill_misconception_ids: []\n' + - ''; - - var expectedHistoryStates = [ - { - label: 'first (was: Introd...', - color: COLOR_CHANGED, - }, - { - label: 'second', - color: COLOR_ADDED, - }, - { - label: 'final card', - color: COLOR_ADDED, - }, - ]; - await explorationEditorPage.navigateToHistoryTab(); - var historyGraph = explorationEditorHistoryTab.getHistoryGraph(); - await historyGraph.selectTwoVersions('1', '2'); - await historyGraph.expectHistoryStatesToMatch(expectedHistoryStates); - await historyGraph.expectNumberOfLinksToMatch(2, 2, 0); - await historyGraph.openStateHistory('first (was: Introd...'); - await historyGraph.expectTextWithHighlightingToMatch( - VERSION_1_STATE_1_CONTENTS, - VERSION_2_STATE_1_CONTENTS - ); - await historyGraph.closeStateHistory(); - - await historyGraph.openStateHistory('second'); - await historyGraph.expectTextToMatch('', STATE_2_STRING); - await historyGraph.closeStateHistory(); - - // Reset all checkboxes. - // Switching the 2 compared versions should give the same result. - await historyGraph.deselectVersion(); - await historyGraph.selectTwoVersions('2', '1'); - await historyGraph.expectHistoryStatesToMatch(expectedHistoryStates); - await historyGraph.expectNumberOfLinksToMatch(2, 2, 0); - - // Check deleting a state. - await explorationEditorPage.navigateToMainTab(); - await explorationEditorMainTab.deleteState('second'); - await explorationEditorMainTab.moveToState('first'); - responseEditor = await explorationEditorMainTab.getResponseEditor(0); - await responseEditor.setDestination('final card', false, null); - await explorationEditorPage.saveChanges(); - - expectedHistoryStates = [ - { - label: 'first', - color: COLOR_CHANGED, - }, - { - label: 'second', - color: COLOR_DELETED, - }, - { - label: 'final card', - color: COLOR_UNCHANGED, - }, - ]; - await explorationEditorPage.navigateToHistoryTab(); - historyGraph = await explorationEditorHistoryTab.getHistoryGraph(); - await historyGraph.deselectVersion(); - await historyGraph.selectTwoVersions('2', '3'); - await historyGraph.expectHistoryStatesToMatch(expectedHistoryStates); - await historyGraph.expectNumberOfLinksToMatch(3, 1, 2); - - await historyGraph.openStateHistory('second'); - await historyGraph.expectTextToMatch(STATE_2_STRING, ''); - await historyGraph.closeStateHistory(); - - // Check renaming a state. - await explorationEditorPage.navigateToMainTab(); - await explorationEditorMainTab.moveToState('first'); - await explorationEditorMainTab.setStateName('third'); - await explorationEditorPage.saveChanges(); - expectedHistoryStates = [ - { - label: 'third (was: first)', - color: COLOR_RENAMED_UNCHANGED, - }, - { - label: 'final card', - color: COLOR_UNCHANGED, - }, - ]; - await explorationEditorPage.navigateToHistoryTab(); - historyGraph = await explorationEditorHistoryTab.getHistoryGraph(); - await historyGraph.selectTwoVersions('3', '4'); - await historyGraph.expectHistoryStatesToMatch(expectedHistoryStates); - await historyGraph.expectNumberOfLinksToMatch(1, 0, 0); - - // Check re-inserting a deleted state. - await explorationEditorPage.navigateToMainTab(); - await explorationEditorMainTab.moveToState('third'); - responseEditor = await explorationEditorMainTab.getResponseEditor(0); - await responseEditor.setDestination('second', true, null); - await explorationEditorMainTab.moveToState('second'); - await explorationEditorMainTab.setContent( - await forms.toRichText('this is card 2'), - true - ); - await explorationEditorMainTab.setInteraction('Continue'); - - var responseEditor = - await explorationEditorMainTab.getResponseEditor('default'); - await responseEditor.setDestination('final card', false, null); - await explorationEditorPage.saveChanges(); - - expectedHistoryStates = [ - { - label: 'third (was: first)', - color: COLOR_CHANGED, - }, - { - label: 'second', - color: COLOR_CHANGED, - }, - { - label: 'final card', - color: COLOR_UNCHANGED, - }, - ]; - await explorationEditorPage.navigateToHistoryTab(); - historyGraph = await explorationEditorHistoryTab.getHistoryGraph(); - await historyGraph.deselectVersion(); - await historyGraph.selectTwoVersions('2', '5'); - await historyGraph.expectHistoryStatesToMatch(expectedHistoryStates); - await historyGraph.expectNumberOfLinksToMatch(2, 0, 0); - - await users.logout(); - }); - - it('should show the history of exploration metadata', async function () { - await users.createUser('user3@historyTab.com', 'user3HistoryTab'); - await users.login('user3@historyTab.com'); - await workflow.createExploration(true); - - var METADATA_1_STRING = - "title: ''\n" + - "category: ''\n" + - "objective: ''\n" + - 'language_code: en\n' + - 'tags: []\n' + - "blurb: ''\n" + - "author_notes: ''\n" + - 'states_schema_version: 57\n' + - 'init_state_name: Introduction\n' + - 'param_specs: {}\n' + - 'param_changes: []\n' + - 'auto_tts_enabled: false\n' + - 'edits_allowed: true\n' + - ''; - - var METADATA_2_STRING = - 'title: Dummy Exploration\n' + - 'category: Algorithms\n' + - 'objective: Learn more about Oppia\n' + - 'language_code: en\n' + - 'tags: []\n' + - "blurb: ''\n" + - "author_notes: ''\n" + - 'states_schema_version: 57\n' + - 'init_state_name: Introduction\n' + - 'param_specs: {}\n' + - 'param_changes: []\n' + - 'auto_tts_enabled: false\n' + - 'edits_allowed: true\n' + - ''; - - await explorationEditorPage.navigateToSettingsTab(); - await explorationEditorSettingsTab.setTitle('Dummy Exploration'); - await explorationEditorSettingsTab.setCategory('Algorithms'); - await explorationEditorSettingsTab.setObjective('Learn more about Oppia'); - await explorationEditorSettingsTab.setLanguage('English'); - await explorationEditorPage.saveChanges(); - await explorationEditorPage.navigateToHistoryTab(); - var historyGraph = await explorationEditorHistoryTab.getHistoryGraph(); - await historyGraph.selectTwoVersions('1', '2'); - await historyGraph.openExplorationMetadataHistory(); - await historyGraph.expectTextToMatch(METADATA_1_STRING, METADATA_2_STRING); - await historyGraph.closeExplorationMetadataHistory(); - await users.logout(); - }); - - it('should revert to old exploration commit', async function () { - await users.createUser('user2@historyTab.com', 'user2HistoryTab'); - await users.login('user2@historyTab.com'); - await workflow.createExploration(true); - - // Make changes for second commit. - // First card. - await explorationEditorMainTab.setStateName('first'); - await explorationEditorMainTab.setContent( - await forms.toRichText('enter 6 to continue'), - true - ); - await explorationEditorMainTab.setInteraction('NumericInput'); - await explorationEditorMainTab.addResponse( - 'NumericInput', - null, - 'second', - true, - 'Equals', - 6 - ); - // Second card. - await explorationEditorMainTab.moveToState('second'); - await explorationEditorMainTab.setContent( - await forms.toRichText('card 2 second commit text'), - true - ); - await explorationEditorMainTab.setInteraction('Continue'); - var responseEditor = - await explorationEditorMainTab.getResponseEditor('default'); - await responseEditor.setDestination('final card', true, null); - // Final card. - await explorationEditorMainTab.moveToState('final card'); - await explorationEditorMainTab.setInteraction('EndExploration'); - await explorationEditorMainTab.moveToState('first'); - await explorationEditorPage.saveChanges(); - - // Create third commit. - await explorationEditorPage.navigateToMainTab(); - await explorationEditorMainTab.moveToState('first'); - await explorationEditorMainTab.setStateName('third'); - await explorationEditorMainTab.moveToState('second'); - await explorationEditorMainTab.setContent( - await forms.toRichText('card 2 third commit text'), - true - ); - await explorationEditorPage.saveChanges(); - expectedHistoryStates = [ - { - label: 'third (was: first)', - color: COLOR_RENAMED_UNCHANGED, - }, - { - label: 'second', - color: COLOR_CHANGED, - }, - { - label: 'final card', - color: COLOR_UNCHANGED, - }, - ]; - await explorationEditorPage.navigateToHistoryTab(); - var historyGraph = await explorationEditorHistoryTab.getHistoryGraph(); - await historyGraph.selectTwoVersions('2', '3'); - await historyGraph.expectHistoryStatesToMatch(expectedHistoryStates); - await historyGraph.expectNumberOfLinksToMatch(2, 0, 0); - // Revert to version 2. - await explorationEditorPage.navigateToHistoryTab(); - await explorationEditorHistoryTab.revertToVersion(2); - await explorationEditorHistoryTab.expectRevertToVersion(2); - - // Verify exploration is version 2. - await general.moveToPlayer(); - await explorationPlayerPage.expectContentToMatch( - await forms.toRichText('enter 6 to continue') - ); - await explorationPlayerPage.submitAnswer('NumericInput', 6); - await explorationPlayerPage.expectExplorationToNotBeOver(); - await explorationPlayerPage.expectContentToMatch( - await forms.toRichText('card 2 second commit text') - ); - await explorationPlayerPage.expectInteractionToMatch( - 'Continue', - 'CONTINUE' - ); - await explorationPlayerPage.submitAnswer('Continue', null); - await explorationPlayerPage.expectExplorationToBeOver(); - - // Verify history states between original and reversion. - await general.moveToEditor(false); - var expectedHistoryStates = [ - { - label: 'first', - color: COLOR_UNCHANGED, - }, - { - label: 'second', - color: COLOR_UNCHANGED, - }, - { - label: 'final card', - color: COLOR_UNCHANGED, - }, - ]; - await explorationEditorPage.navigateToHistoryTab(); - historyGraph = await explorationEditorHistoryTab.getHistoryGraph(); - await historyGraph.selectTwoVersions('2', '4'); - await historyGraph.expectHistoryStatesToMatch(expectedHistoryStates); - await historyGraph.expectNumberOfLinksToMatch(2, 0, 0); - - await users.logout(); - }); - - afterEach(async function () { - await general.checkForConsoleErrors([]); - }); -}); diff --git a/core/tests/webdriverio_desktop/skillEditor.js b/core/tests/webdriverio_desktop/skillEditor.js deleted file mode 100644 index 923efb0fb696b..0000000000000 --- a/core/tests/webdriverio_desktop/skillEditor.js +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright 2022 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview End-to-end tests for the skill editor page. - */ - -var general = require('../webdriverio_utils/general.js'); -var users = require('../webdriverio_utils/users.js'); -var workflow = require('../webdriverio_utils/workflow.js'); - -var ExplorationEditorPage = require('../webdriverio_utils/ExplorationEditorPage.js'); -var TopicsAndSkillsDashboardPage = require('../webdriverio_utils/TopicsAndSkillsDashboardPage.js'); -var SkillEditorPage = require('../webdriverio_utils/SkillEditorPage.js'); - -describe('Skill Editor functionality', function () { - var topicsAndSkillsDashboardPage = null; - var skillEditorPage = null; - var skillId = null; - var explorationEditorPage = null; - - beforeAll(async function () { - topicsAndSkillsDashboardPage = - new TopicsAndSkillsDashboardPage.TopicsAndSkillsDashboardPage(); - skillEditorPage = new SkillEditorPage.SkillEditorPage(); - explorationEditorPage = new ExplorationEditorPage.ExplorationEditorPage(); - explorationEditorMainTab = explorationEditorPage.getMainTab(); - await users.createAndLoginCurriculumAdminUser( - 'creator@skillEditor.com', - 'creatorSkillEditor' - ); - await topicsAndSkillsDashboardPage.get(); - var handle = await browser.getWindowHandle(); - await topicsAndSkillsDashboardPage.createSkillWithDescriptionAndExplanation( - 'Skill 1', - 'Concept card explanation', - false - ); - var url = await browser.getUrl(); - skillId = url.split('/')[4]; - await general.closeCurrentTabAndSwitchTo(handle); - await users.logout(); - }); - - beforeEach(async function () { - await users.login('creator@skillEditor.com'); - await skillEditorPage.get(skillId); - }); - - it('should edit description and concept card explanation', async function () { - await skillEditorPage.changeSkillDescription('Skill 1 edited'); - await skillEditorPage.editConceptCard('Test concept card explanation'); - await skillEditorPage.saveOrPublishSkill( - 'Changed skill description and added review material.' - ); - - await topicsAndSkillsDashboardPage.get(); - await topicsAndSkillsDashboardPage.expectSkillDescriptionToBe( - 'Skill 1 edited', - 0 - ); - - await skillEditorPage.get(skillId); - await skillEditorPage.expectSkillDescriptionToBe('Skill 1 edited'); - await skillEditorPage.expectConceptCardExplanationToMatch( - 'Test concept card explanation' - ); - }); - - it('should edit rubrics for the skill', async function () { - await skillEditorPage.addRubricExplanationForDifficulty( - 'Easy', - 'Explanation for easy difficulty' - ); - await skillEditorPage.addRubricExplanationForDifficulty( - 'Medium', - 'Explanation for medium difficulty' - ); - await skillEditorPage.addRubricExplanationForDifficulty( - 'Hard', - 'Explanation for hard difficulty' - ); - - await skillEditorPage.expectRubricExplanationsToMatch('Easy', [ - 'Explanation for easy difficulty', - ]); - await skillEditorPage.expectRubricExplanationsToMatch('Medium', [ - 'Skill 1', - 'Explanation for medium difficulty', - ]); - await skillEditorPage.expectRubricExplanationsToMatch('Hard', [ - 'Explanation for hard difficulty', - ]); - - await skillEditorPage.addRubricExplanationForDifficulty( - 'Easy', - 'Second explanation for easy difficulty.' - ); - await skillEditorPage.addRubricExplanationForDifficulty( - 'Medium', - 'Second explanation for medium difficulty.' - ); - await skillEditorPage.addRubricExplanationForDifficulty( - 'Hard', - 'Second explanation for hard difficulty.' - ); - - await skillEditorPage.editRubricExplanationWithIndex( - 'Easy', - 0, - 'Easy explanation 1 edited' - ); - await skillEditorPage.editRubricExplanationWithIndex( - 'Easy', - 1, - 'Easy explanation 2 edited' - ); - await skillEditorPage.editRubricExplanationWithIndex( - 'Medium', - 1, - 'Medium explanation 1 edited' - ); - await skillEditorPage.editRubricExplanationWithIndex( - 'Medium', - 2, - 'Medium explanation 2 edited' - ); - await skillEditorPage.deleteRubricExplanationWithIndex('Medium', 0); - await skillEditorPage.editRubricExplanationWithIndex( - 'Hard', - 0, - 'Hard explanation 1 edited' - ); - await skillEditorPage.editRubricExplanationWithIndex( - 'Hard', - 1, - 'Hard explanation 2 edited' - ); - - await skillEditorPage.saveOrPublishSkill('Edited rubrics'); - - await skillEditorPage.get(skillId); - await skillEditorPage.expectRubricExplanationsToMatch('Easy', [ - 'Easy explanation 1 edited', - 'Easy explanation 2 edited', - ]); - await skillEditorPage.expectRubricExplanationsToMatch('Medium', [ - 'Medium explanation 1 edited', - 'Medium explanation 2 edited', - ]); - await skillEditorPage.expectRubricExplanationsToMatch('Hard', [ - 'Hard explanation 1 edited', - 'Hard explanation 2 edited', - ]); - }); - - it('should create and edit a question for the skill', async function () { - await workflow.createQuestion(); - await skillEditorPage.get(skillId); - await skillEditorPage.moveToQuestionsTab(); - await skillEditorPage.expectNumberOfQuestionsToBe(1); - await skillEditorPage.expectQuestionInteractionIdToMatch('TextInput'); - await skillEditorPage.clickEditQuestionButton(); - await workflow.changeQuestionInteraction(); - await skillEditorPage.saveChangesToQuestion('Updated Question'); - await skillEditorPage.expectQuestionInteractionIdToMatch('NumericInput'); - }); - - it('should create and delete misconceptions', async function () { - await skillEditorPage.addMisconception( - 'Misconception 1', - 'Notes 1', - 'Feedback 1' - ); - await skillEditorPage.addMisconception( - 'Misconception 2', - 'Notes 2', - 'Feedback 2' - ); - await skillEditorPage.saveOrPublishSkill('Added misconceptions'); - - await skillEditorPage.get(skillId); - await skillEditorPage.expectNumberOfMisconceptionsToBe(2); - - await skillEditorPage.deleteMisconception(1); - await skillEditorPage.saveOrPublishSkill('Deleted a misconception'); - - await skillEditorPage.get(skillId); - await skillEditorPage.expectNumberOfMisconceptionsToBe(1); - }); - - it('should show stale tab and unsaved changes status info modals', async function () { - await topicsAndSkillsDashboardPage.get(); - var handle = await browser.getWindowHandle(); - await topicsAndSkillsDashboardPage.createSkillWithDescriptionAndExplanation( - 'Skill 2', - 'Concept card explanation', - false - ); - await browser.switchToWindow(handle); - await topicsAndSkillsDashboardPage.navigateToSkillWithDescription( - 'Skill 2' - ); - - var handles = await browser.getWindowHandles(); - - await skillEditorPage.changeSkillDescription('new description'); - await browser.switchToWindow(handles[handles.length - 1]); - await skillEditorPage.expectUnsavedChangesStatusInfoModalToBeVisible(); - - await browser.switchToWindow(handles[handles.length - 2]); - await skillEditorPage.saveOrPublishSkill('Changed skill description.'); - await browser.switchToWindow(handles[handles.length - 1]); - await skillEditorPage.expectStaleTabInfoModalToBeVisible(); - - await general.closeCurrentTabAndSwitchTo(handles[handles.length - 2]); - }); - - afterEach(async function () { - await general.checkForConsoleErrors([]); - await users.logout(); - }); -}); diff --git a/core/tests/webdriverio_desktop/topicAndStoryEditor.js b/core/tests/webdriverio_desktop/topicAndStoryEditor.js deleted file mode 100644 index 2cf161d5d4952..0000000000000 --- a/core/tests/webdriverio_desktop/topicAndStoryEditor.js +++ /dev/null @@ -1,459 +0,0 @@ -// Copyright 2022 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview End-to-end tests for the topic editor page. - */ - -var forms = require('../webdriverio_utils/forms.js'); -var general = require('../webdriverio_utils/general.js'); -var users = require('../webdriverio_utils/users.js'); -var waitFor = require('../webdriverio_utils/waitFor.js'); -var workflow = require('../webdriverio_utils/workflow.js'); - -var Constants = require('../webdriverio_utils/WebdriverioConstants.js'); -var TopicsAndSkillsDashboardPage = require('../webdriverio_utils/TopicsAndSkillsDashboardPage.js'); -var TopicEditorPage = require('../webdriverio_utils/TopicEditorPage.js'); -var StoryEditorPage = require('../webdriverio_utils/StoryEditorPage.js'); -var SkillEditorPage = require('../webdriverio_utils/SkillEditorPage.js'); -var ExplorationEditorPage = require('../webdriverio_utils/ExplorationEditorPage.js'); -var ExplorationPlayerPage = require('../webdriverio_utils/ExplorationPlayerPage.js'); - -describe('Topic editor functionality', function () { - var topicsAndSkillsDashboardPage = null; - var topicEditorPage = null; - var storyEditorPage = null; - var topicId = null; - var skillEditorPage = null; - var explorationEditorPage = null; - var explorationEditorMainTab = null; - - beforeAll(async function () { - topicsAndSkillsDashboardPage = - new TopicsAndSkillsDashboardPage.TopicsAndSkillsDashboardPage(); - topicEditorPage = new TopicEditorPage.TopicEditorPage(); - storyEditorPage = new StoryEditorPage.StoryEditorPage(); - skillEditorPage = new SkillEditorPage.SkillEditorPage(); - explorationEditorPage = new ExplorationEditorPage.ExplorationEditorPage(); - explorationEditorMainTab = explorationEditorPage.getMainTab(); - await users.createAndLoginCurriculumAdminUser( - 'creator@topicEditor.com', - 'creatorTopicEditor' - ); - var handle = await browser.getWindowHandle(); - await topicsAndSkillsDashboardPage.get(); - await topicsAndSkillsDashboardPage.createTopic( - 'Topic 1', - 'unique-topic', - 'Description', - false - ); - var url = await browser.getUrl(); - topicId = url.split('/')[4]; - await general.closeCurrentTabAndSwitchTo(handle); - await users.logout(); - }); - - beforeEach(async function () { - await users.login('creator@topicEditor.com'); - await topicEditorPage.get(topicId); - }); - - it('should add and delete subtopics correctly', async function () { - await topicEditorPage.addSubtopic( - 'Subtopic 1', - 'subtopic-one', - '../data/test2_svg.svg', - 'Subtopic content' - ); - await topicEditorPage.saveTopic('Added subtopic.'); - await users.logout(); - await users.login('creator@topicEditor.com'); - await topicEditorPage.get(topicId); - await topicEditorPage.addSubtopic( - 'Subtopic 2', - 'subtopic-two', - '../data/test2_svg.svg', - 'Subtopic content' - ); - await topicEditorPage.saveTopic('Added subtopic.'); - await users.logout(); - - await users.login('creator@topicEditor.com'); - await topicEditorPage.get(topicId); - await topicEditorPage.expectNumberOfSubtopicsToBe(2); - await topicEditorPage.deleteSubtopicWithIndex(0); - await topicEditorPage.saveTopic('Deleted subtopic.'); - await topicEditorPage.expectNumberOfSubtopicsToBe(1); - }); - - it('should create a question for a skill in the topic', async function () { - var skillId = null; - var handle = await browser.getWindowHandle(); - await topicsAndSkillsDashboardPage.get(); - await topicsAndSkillsDashboardPage.createSkillWithDescriptionAndExplanation( - 'Skill 1', - 'Concept card explanation', - false - ); - var url = await browser.getUrl(); - skillId = url.split('/')[4]; - await general.closeCurrentTabAndSwitchTo(handle); - await topicsAndSkillsDashboardPage.get(); - await topicsAndSkillsDashboardPage.navigateToSkillsTab(); - await topicsAndSkillsDashboardPage.filterSkillsByStatus( - Constants.SKILL_STATUS_UNASSIGNED - ); - await topicsAndSkillsDashboardPage.assignSkillToTopic('Skill 1', 'Topic 1'); - - await skillEditorPage.get(skillId); - await skillEditorPage.addRubricExplanationForDifficulty( - 'Easy', - 'Second explanation for easy difficulty.' - ); - await skillEditorPage.saveOrPublishSkill('Edited rubrics'); - - await topicEditorPage.get(topicId); - await topicEditorPage.moveToQuestionsTab(); - await topicEditorPage.createQuestionForSkillWithName('Skill 1'); - await explorationEditorMainTab.setContent( - await forms.toRichText('Question 1'), - true - ); - await explorationEditorMainTab.setInteraction( - 'TextInput', - 'Placeholder', - 5 - ); - await explorationEditorMainTab.addResponse( - 'TextInput', - await forms.toRichText('Correct Answer'), - null, - false, - 'FuzzyEquals', - ['correct'] - ); - var responseEditor = await explorationEditorMainTab.getResponseEditor(0); - await responseEditor.markAsCorrect(); - await ( - await explorationEditorMainTab.getResponseEditor('default') - ).setFeedback(await forms.toRichText('Try again')); - await explorationEditorMainTab.addHint('Hint 1'); - await explorationEditorMainTab.addSolution('TextInput', { - correctAnswer: 'correct', - explanation: 'It is correct', - }); - await topicEditorPage.saveQuestion(); - - await topicEditorPage.get(topicId); - await topicEditorPage.moveToQuestionsTab(); - await topicEditorPage.expectNumberOfQuestionsForSkillWithDescriptionToBe( - 1, - 'Skill 1' - ); - - await skillEditorPage.get(skillId); - await skillEditorPage.moveToQuestionsTab(); - await skillEditorPage.expectNumberOfQuestionsToBe(1); - }); - - it('should add a canonical story to topic correctly', async function () { - await topicEditorPage.expectNumberOfStoriesToBe(0); - await topicEditorPage.createStory( - 'Story Title', - 'topicandstoryeditorone', - 'Story description', - Constants.TEST_SVG_PATH - ); - await storyEditorPage.returnToTopic(); - - await topicEditorPage.expectNumberOfStoriesToBe(1); - }); - - it('should edit story title, description and notes correctly', async function () { - await topicEditorPage.navigateToStoryWithIndex(0); - await storyEditorPage.changeStoryNotes( - await forms.toRichText('Story notes') - ); - await storyEditorPage.changeStoryTitle('Story Title Edited'); - await storyEditorPage.changeStoryDescription('Story Description'); - await storyEditorPage.saveStory( - 'Changed story title, description and notes' - ); - - await storyEditorPage.returnToTopic(); - await topicEditorPage.expectStoryTitleToBe('Story Title Edited', 0); - await topicEditorPage.navigateToStoryWithIndex(0); - - await storyEditorPage.expectTitleToBe('Story Title Edited'); - await storyEditorPage.expectDescriptionToBe('Story Description'); - await storyEditorPage.expectNotesToBe( - await forms.toRichText('Story notes') - ); - }); - - afterEach(async function () { - await general.checkForConsoleErrors([]); - await users.logout(); - }); -}); - -describe('Chapter editor functionality', function () { - var topicsAndSkillsDashboardPage = null; - var topicEditorPage = null; - var storyEditorPage = null; - var storyName = 'Story 0'; - var explorationEditorPage = null; - var dummyExplorationIds = []; - var dummyExplorationInfo = [ - 'Dummy exploration', - 'Algorithms', - 'Learn more about oppia', - 'English', - ]; - var dummySkills = []; - var allowedErrors = []; - var topicName = 'Topic 0'; - var topicUrlFragment = 'topic-zero'; - var userEmail = 'creator@chapterTest.com'; - - var createDummyExplorations = async function (numExplorations) { - var ids = []; - for (var i = 0; i < numExplorations; i++) { - var info = dummyExplorationInfo.slice(); - info[0] += i.toString(); - if (i === 0) { - info.push(true); - await workflow.createAndPublishExploration.apply(workflow, info); - } else { - info.push(false); - await workflow.createAndPublishExploration.apply(workflow, info); - } - var url = await browser.getUrl(); - var id = url.split('/')[4].replace('#', ''); - ids.push(id); - } - return ids; - }; - - var createDummySkills = async function (numSkills) { - var skills = []; - for (var i = 0; i < numSkills; i++) { - var skillName = 'skillFromChapterEditor' + i.toString(); - var material = 'reviewMaterial' + i.toString(); - await workflow.createSkillAndAssignTopic(skillName, material, topicName); - skills.push(skillName); - } - return skills; - }; - - beforeAll(async function () { - topicsAndSkillsDashboardPage = - new TopicsAndSkillsDashboardPage.TopicsAndSkillsDashboardPage(); - topicEditorPage = new TopicEditorPage.TopicEditorPage(); - storyEditorPage = new StoryEditorPage.StoryEditorPage(); - skillEditorPage = new SkillEditorPage.SkillEditorPage(); - explorationEditorPage = new ExplorationEditorPage.ExplorationEditorPage(); - explorationPlayerPage = new ExplorationPlayerPage.ExplorationPlayerPage(); - explorationEditorMainTab = explorationEditorPage.getMainTab(); - await users.createAndLoginCurriculumAdminUser( - userEmail, - 'creatorChapterTest' - ); - var handle = await browser.getWindowHandle(); - dummyExplorationIds = await createDummyExplorations(3); - await topicsAndSkillsDashboardPage.get(); - await topicsAndSkillsDashboardPage.createTopic( - topicName, - topicUrlFragment, - 'Description', - false - ); - await topicEditorPage.createStory( - storyName, - 'topicandstoryeditortwo', - 'Story description', - Constants.TEST_SVG_PATH - ); - await general.closeCurrentTabAndSwitchTo(handle); - dummySkills = await createDummySkills(2); - await users.logout(); - }); - - beforeEach(async function () { - await users.login(userEmail); - await topicsAndSkillsDashboardPage.get(); - await topicsAndSkillsDashboardPage.editTopic(topicName); - await topicEditorPage.navigateToStoryWithTitle(storyName); - }); - - it('should create a basic chapter.', async function () { - await storyEditorPage.createNewChapter( - 'Chapter 1', - dummyExplorationIds[0], - Constants.TEST_SVG_PATH - ); - await storyEditorPage.navigateToChapterWithName('Chapter 1'); - await storyEditorPage.changeNodeDescription('Chapter description 1'); - await storyEditorPage.changeNodeOutline( - await forms.toRichText('First outline') - ); - await storyEditorPage.saveStory('First save'); - await users.logout(); - await users.login(userEmail); - await topicsAndSkillsDashboardPage.get(); - await topicsAndSkillsDashboardPage.editTopic(topicName); - await topicEditorPage.navigateToStoryWithIndex(0); - await storyEditorPage.navigateToChapterWithName('Chapter 1'); - await storyEditorPage.expectNodeDescription('Chapter description 1'); - }); - - it( - 'should check presence of skillreview RTE element in exploration ' + - 'linked to story', - async function () { - await browser.url('/create/' + dummyExplorationIds[0]); - await waitFor.pageToFullyLoad(); - await explorationEditorMainTab.setContent(async function ( - richTextEditor - ) { - await richTextEditor.addRteComponent( - 'Skillreview', - 'Description', - 'skillFromChapterEditor0' - ); - }, true); - await explorationEditorMainTab.leaveTranslationsAsIs(); - await explorationEditorPage.navigateToPreviewTab(); - await explorationPlayerPage.expectContentToMatch( - async function (richTextChecker) { - await richTextChecker.readRteComponent( - 'Skillreview', - 'Description', - await forms.toRichText('reviewMaterial0') - ); - } - ); - } - ); - - it('should add one more chapter to the story', async function () { - await storyEditorPage.createNewChapter( - 'Chapter 2', - dummyExplorationIds[1], - Constants.TEST_SVG_PATH - ); - await storyEditorPage.navigateToChapterWithName('Chapter 2'); - await storyEditorPage.changeNodeDescription('Chapter description 2'); - await storyEditorPage.changeNodeOutline( - await forms.toRichText('Second outline') - ); - await storyEditorPage.saveStory('Second save'); - await users.logout(); - await users.login(userEmail); - await topicsAndSkillsDashboardPage.get(); - await topicsAndSkillsDashboardPage.editTopic(topicName); - await topicEditorPage.navigateToStoryWithIndex(0); - await storyEditorPage.navigateToChapterWithName('Chapter 2'); - await storyEditorPage.expectNodeDescription('Chapter description 2'); - }); - - it('should fail to add one more chapter with existing exploration', async function () { - await storyEditorPage.createNewChapter( - 'Chapter 3', - dummyExplorationIds[1], - Constants.TEST_SVG_PATH - ); - await storyEditorPage.expectExplorationIdAlreadyExistWarning(); - await storyEditorPage.cancelChapterCreation(); - await storyEditorPage.discardStoryChanges(); - }); - - it('should add one more chapter and change the chapters sequences', async function () { - await storyEditorPage.createNewChapter( - 'Chapter 3', - dummyExplorationIds[2], - Constants.TEST_SVG_PATH - ); - await storyEditorPage.navigateToChapterWithName('Chapter 3'); - await storyEditorPage.navigateToStoryEditorTab(); - await storyEditorPage.expectChaptersListToBe([ - 'Chapter 1', - 'Chapter 2', - 'Chapter 3', - ]); - - await storyEditorPage.saveStory('Saving chapters'); - }); - - it('should add one prerequisite and acquired skill to chapter 1', async function () { - await storyEditorPage.navigateToChapterWithName('Chapter 1'); - await storyEditorPage.expectAcquiredSkillDescriptionCardCount(0); - await storyEditorPage.expectPrerequisiteSkillDescriptionCardCount(0); - await storyEditorPage.addAcquiredSkill(dummySkills[0]); - await storyEditorPage.expectAcquiredSkillDescriptionCardCount(1); - await storyEditorPage.saveStory('Save'); - await storyEditorPage.navigateToStoryEditorTab(); - - await storyEditorPage.navigateToChapterWithName('Chapter 2'); - await storyEditorPage.expectAcquiredSkillDescriptionCardCount(0); - await storyEditorPage.expectPrerequisiteSkillDescriptionCardCount(0); - await storyEditorPage.addAcquiredSkill(dummySkills[1]); - await storyEditorPage.expectAcquiredSkillDescriptionCardCount(1); - await storyEditorPage.addPrerequisiteSkill(dummySkills[0]); - await storyEditorPage.expectPrerequisiteSkillDescriptionCardCount(1); - await storyEditorPage.saveStory('Save'); - }); - - it( - 'should fail to add one prerequisite skill which is already added as' + - ' acquired skill', - async function () { - await storyEditorPage.navigateToChapterWithName('Chapter 1'); - await storyEditorPage.addPrerequisiteSkill(dummySkills[0]); - await storyEditorPage.expectSaveStoryDisabled(); - var warningRegex = new RegExp( - 'The skill with id [a-zA-Z0-9]+ is common to both the acquired and ' + - 'prerequisite skill id ' + - 'list in .*' - ); - await storyEditorPage.expectWarningInIndicator(warningRegex); - await storyEditorPage.discardStoryChanges(); - } - ); - - it('should delete prerequisite skill and acquired skill', async function () { - await storyEditorPage.navigateToChapterWithName('Chapter 2'); - await storyEditorPage.deleteAcquiredSkillByIndex(0); - await storyEditorPage.expectAcquiredSkillDescriptionCardCount(0); - await storyEditorPage.deletePrerequisiteSkillByIndex(0); - await storyEditorPage.saveStory('Deleted skill'); - await storyEditorPage.expectPrerequisiteSkillDescriptionCardCount(0); - }); - - it('should delete one chapter and save', async function () { - await storyEditorPage.expectNumberOfChaptersToBe(3); - await storyEditorPage.deleteChapterWithIndex(1); - await storyEditorPage.expectNumberOfChaptersToBe(2); - await storyEditorPage.saveStory('Last'); - }); - - afterEach(async function () { - await general.checkForConsoleErrors(allowedErrors); - await users.logout(); - while (allowedErrors.length !== 0) { - allowedErrors.pop(); - } - }); -}); diff --git a/core/tests/webdriverio_utils/AdminPage.js b/core/tests/webdriverio_utils/AdminPage.js deleted file mode 100644 index f54b8e1db5f9c..0000000000000 --- a/core/tests/webdriverio_utils/AdminPage.js +++ /dev/null @@ -1,286 +0,0 @@ -// Copyright 2022 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview Page object for the admin page, for use in WebdriverIO - * tests. - */ - -var action = require('./action.js'); -var general = require('./general.js'); -var waitFor = require('./waitFor.js'); - -var AdminPage = function () { - var ADMIN_URL_SUFFIX = '/admin'; - var addNewRoleButton = $('.e2e-test-add-new-role-button'); - var adminRolesTab = $('.e2e-test-admin-roles-tab'); - var adminRolesTabContainer = $('.e2e-test-roles-tab-container'); - var editUserRoleButton = $('.e2e-test-role-edit-button'); - var explorationElementsSelector = function () { - return $$('.e2e-test-reload-exploration-row'); - }; - var progressSpinner = $('.e2e-test-progress-spinner'); - var reloadCollectionButtonsSelector = function () { - return $$('.e2e-test-reload-collection-button'); - }; - var reloadCollectionButtonsSelector = function () { - return $$('.e2e-test-reload-collection-button'); - }; - var roleDropdown = $('.e2e-test-role-method'); - var roleEditorContainer = $('.e2e-test-roles-editor-card-container'); - var rolesResultRowsElement = $('.e2e-test-roles-result-rows'); - var rolesResultRowsElementsSelector = function () { - return $$('.e2e-test-roles-result-rows'); - }; - var roleSelector = $('.e2e-test-new-role-selector'); - var roleValueOption = $('.e2e-test-role-value'); - - var statusMessage = $('.e2e-test-status-message'); - var userRoleItemsSelector = function () { - return $$('.e2e-test-user-role-description'); - }; - var usernameInputFieldForRolesEditing = $( - '.e2e-test-username-for-role-editor' - ); - var viewRoleButton = $('.e2e-test-role-success'); - var languageSelectorModal = $('.e2e-test-language-selector-modal'); - var languageSelector = $('.e2e-test-language-selector'); - var languageSelectorCloseButton = $( - '.e2e-test-language-selector-close-button' - ); - var languageSelectorAddButton = $('.e2e-test-language-selector-add-button'); - var editLanguageButton = $('.e2e-test-edit-language-button'); - - // The reload functions are used for mobile testing - // done via Browserstack. These functions may cause - // a problem when used to run tests directly on Travis. - if (general.isInDevMode()) { - var getExplorationTitleElement = function (explorationElement) { - return explorationElement.$(explorationTitleLocator); - }; - - var getExplorationElementReloadButton = function (explorationElement) { - return explorationElement.$(explorationButtonLocator); - }; - - this.reloadCollection = async function (collectionId) { - await this.get(); - var reloadCollectionButton = - await reloadCollectionButtonsSelector()[collectionId]; - await action.click('Reload Collection Buttons', reloadCollectionButton); - await general.acceptAlert(); - // Time is needed for the reloading to complete. - await waitFor.textToBePresentInElement( - statusMessage, - 'Data reloaded successfully.', - 'Collection could not be reloaded' - ); - return true; - }; - - // The name should be as given in the admin page (including '.yaml' if - // necessary). - this.reloadExploration = async function (name) { - await this.get(); - var explorationElements = explorationElementsSelector(); - await explorationElements.map(async function (explorationElement) { - await waitFor.visibilityOf( - getExplorationTitleElement( - explorationElement, - 'Exploration title taking too long to appear' - ) - ); - var title = - await getExplorationTitleElement(explorationElement).getText(); - - // We use match here in case there is whitespace around the name. - if (title.match(name)) { - var explorationElementReloadButton = - getExplorationElementReloadButton(explorationElement); - await action.click( - 'Exploration Element Reload Button', - explorationElementReloadButton - ); - await general.acceptAlert(); - // Time is needed for the reloading to complete. - await waitFor.textToBePresentInElement( - statusMessage, - 'Data reloaded successfully.', - 'Exploration could not be reloaded' - ); - return true; - } - }); - }; - } - - var _switchToRolesTab = async function () { - let width = (await browser.getWindowSize()).width; - if (width < 711) { - // TODO(#15562): Add proper mobile navigation after this has been fixed. - await browser.url('/admin#/roles'); - } else { - await action.click('Admin roles tab button', adminRolesTab); - } - - await expect(await adminRolesTab.getAttribute('class')).toMatch('active'); - await waitFor.visibilityOf( - adminRolesTabContainer, - 'Roles tab page is not visible.' - ); - }; - - this.get = async function () { - await browser.url(ADMIN_URL_SUFFIX); - await waitFor.pageToFullyLoad(); - }; - - this.getJobsTab = async function () { - await browser.url(ADMIN_URL_SUFFIX + '#/jobs'); - await waitFor.pageToFullyLoad(); - }; - - this._editUserRole = async function (username) { - await this.get(); - await _switchToRolesTab(); - await action.setValue( - 'Username input field', - usernameInputFieldForRolesEditing, - username - ); - await action.click('Edit user role button', editUserRoleButton); - await waitFor.visibilityOf( - roleEditorContainer, - 'Role editor card takes too long to appear.' - ); - }; - - this.addRole = async function (name, newRole) { - await this._editUserRole(name); - - await action.click('Add new role', addNewRoleButton); - await action.matSelect('New role selector', roleSelector, newRole); - - await waitFor.invisibilityOf( - progressSpinner, - 'Progress spinner is taking too long to disappear.' - ); - var removeButtonElement = $( - '.e2e-test-' + newRole.split(' ').join('-') + '-remove-button-container' - ); - await waitFor.visibilityOf( - removeButtonElement, - 'Role removal button takes too long to appear.' - ); - }; - - this._selectLanguage = async function (language) { - await waitFor.visibilityOf( - languageSelectorModal, - 'Language selector modal taking too long to appear' - ); - await action.select('Language selector', languageSelector, language); - await action.click('Add language button', languageSelectorAddButton); - await action.click('Close button', languageSelectorCloseButton); - await waitFor.invisibilityOf( - languageSelectorModal, - 'Language selector modal taking too long to disappear' - ); - }; - - this.makeUserTranslationCoordinator = async function (name, language) { - await this._editUserRole(name); - - await action.click('Add new role', addNewRoleButton); - await action.matSelect( - 'New role selector', - roleSelector, - 'translation coordinator' - ); - - await this._selectLanguage(language); - - await waitFor.invisibilityOf( - progressSpinner, - 'Progress spinner is taking too long to disappear.' - ); - newRole = 'translation coordinator'; - var removeButtonElement = $( - '.e2e-test-' + newRole.split(' ').join('-') + '-remove-button-container' - ); - await waitFor.visibilityOf( - removeButtonElement, - 'Role removal button takes too long to appear.' - ); - }; - - this.addLanguageToCoordinator = async function (name, language) { - await this._editUserRole(name); - await action.click('Edit coordinated languages button', editLanguageButton); - await this._selectLanguage(language); - }; - - this.getUsersAsssignedToRole = async function (role) { - await action.select('Role Drop Down', roleDropdown, 'By Role'); - await action.select('Role Value Option', roleValueOption, role); - await action.click('View Role Button', viewRoleButton); - }; - - this.expectUserRolesToMatch = async function (name, expectedRoles) { - await this._editUserRole(name); - var userRoleItems = userRoleItemsSelector(); - var userRoles = await userRoleItems.map(async function (userRoleContainer) { - return ( - await action.getText('Role container', userRoleContainer) - ).toLowerCase(); - }); - expect(expectedRoles.length).toEqual(userRoles.length); - expectedRoles.sort(); - userRoles.sort(); - for (j = 0; j < userRoles.length; j++) { - var name = userRoles[j]; - expect(name).toEqual(expectedRoles[j]); - } - }; - - this.expectUsernamesToMatch = async function (expectedUsernamesArray) { - var foundUsersArray = []; - if (expectedUsernamesArray.length !== 0) { - await waitFor.visibilityOf(rolesResultRowsElement); - } - var rolesResultRowsElements = rolesResultRowsElementsSelector(); - var usernames = await rolesResultRowsElements.map(async function (elm) { - var text = await action.getText( - 'Username in roles list on admin page', - elm - ); - return text; - }); - - for (i = 0; i < usernames.length; i++) { - var name = usernames[i]; - foundUsersArray.push(name); - } - expect(foundUsersArray.length).toEqual(expectedUsernamesArray.length); - - expectedUsernamesArray.sort(); - foundUsersArray.sort(); - for (j = 0; j < foundUsersArray.length; j++) { - var name = foundUsersArray[j]; - expect(name).toEqual(expectedUsernamesArray[j]); - } - }; -}; - -exports.AdminPage = AdminPage; diff --git a/core/tests/webdriverio_utils/CreatorDashboardPage.js b/core/tests/webdriverio_utils/CreatorDashboardPage.js deleted file mode 100644 index dcf0247a40b5b..0000000000000 --- a/core/tests/webdriverio_utils/CreatorDashboardPage.js +++ /dev/null @@ -1,290 +0,0 @@ -// Copyright 2022 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview Page object for the creator dashboard, for use in WebdriverIO - * tests. - */ - -var waitFor = require('./waitFor.js'); -var action = require('./action.js'); - -var CreatorDashboardPage = function () { - var CREATOR_DASHBOARD_URL = '/creator-dashboard'; - - var allExplorationCardsSelector = function () { - return $$('.e2e-test-exploration-dashboard-card'); - }; - var activityCreationModal = $('.e2e-test-creation-modal'); - var averageRating = $('.e2e-test-oppia-average-rating'); - var collectionCard = $('.e2e-test-collection-card'); - var collectionEditorContainer = $( - '.e2e-test-collection-editor-cards-container' - ); - var createActivityButton = $('.e2e-test-create-activity'); - var createCollectionButton = $('.e2e-test-create-collection'); - var createExplorationButton = $('.e2e-test-create-exploration'); - var createNewExplorationButton = $('.e2e-test-create-new-exploration-button'); - var explorationDashboardCard = $('.e2e-test-exploration-dashboard-card'); - var explorationFeedbackCount = $('.e2e-test-exp-summary-tile-open-feedback'); - var expSummaryRowFeedbackElement = $( - '.e2e-test-exp-summary-row-open-feedback' - ); - var expSummaryRowFeedbackElementsSelector = function () { - return $$('.e2e-test-exp-summary-row-open-feedback'); - }; - var expSummaryRowRatingElement = $('.e2e-test-exp-summary-row-rating'); - var expSummaryRowRatingElementsSelector = function () { - return $$('.e2e-test-exp-summary-row-rating'); - }; - var expSummaryRowTitleElement = $('.e2e-test-exp-summary-row-title'); - var expSummaryRowTitleElementsSelector = function () { - return $$('.e2e-test-exp-summary-row-title'); - }; - var expSummaryRowViewsElement = $('.e2e-test-exp-summary-row-num-views'); - var expSummaryRowViewsElementsSelector = function () { - return $$('.e2e-test-exp-summary-row-num-views'); - }; - var expSummaryTileFeedbackElement = $( - '.e2e-test-exp-summary-tile-open-feedback' - ); - var expSummaryTileFeedbackElementsSelector = function () { - return $$('.e2e-test-exp-summary-tile-open-feedback'); - }; - var expSummaryTileRatingElement = $('.e2e-test-exp-summary-tile-rating'); - var expSummaryTileRatingElementsSelector = function () { - return $$('.e2e-test-exp-summary-tile-rating'); - }; - var expSummaryTileTitleElement = $('.e2e-test-exp-summary-tile-title'); - var expSummaryTileTitleElementsSelector = function () { - return $$('.e2e-test-exp-summary-tile-title'); - }; - var expSummaryTileViewsElement = $('.e2e-test-exp-summary-tile-num-views'); - var expSummaryTileViewsElementsSelector = function () { - return $$('.e2e-test-exp-summary-tile-num-views'); - }; - var listViewButton = $('.e2e-test-oppia-list-view-btn'); - // Dashboard stat elements. - var openFeedbacks = $('.e2e-test-oppia-open-feedback'); - var subscriptionTab = $('.e2e-test-subscription-tab'); - var subscribers = $('.e2e-test-oppia-total-subscribers'); - var totalPlays = $('.e2e-test-oppia-total-plays'); - - // Returns all exploration card elements with the given name. - var _getExplorationElements = async function (explorationTitle) { - await waitFor.visibilityOf(explorationDashboardCard); - var allExplorationCards = allExplorationCardsSelector(); - return await allExplorationCards.filter(async function (tile) { - var text = await tile.getText(); - // Tile text contains title, possibly followed by newline and text. - return ( - text.startsWith(explorationTitle + '\n') || text === explorationTitle - ); - }); - }; - - this.get = async function () { - await browser.url(CREATOR_DASHBOARD_URL); - await waitFor.pageToFullyLoad(); - }; - - this.getNumberOfFeedbackMessages = async function () { - var feedbackCount = await explorationFeedbackCount.getText(); - return parseInt(feedbackCount); - }; - - this.navigateToExplorationEditor = async function () { - await action.click('Exploration Dashboard Card', explorationDashboardCard); - await waitFor.pageToFullyLoad(); - }; - - this.clickCreateActivityButton = async function () { - await action.click('Create Activity Button', createActivityButton); - await waitFor.pageToFullyLoad(); - }; - - this.clickCreateCollectionButton = async function () { - await waitFor.visibilityOf( - activityCreationModal, - 'Activity Creation modal is not visible' - ); - await action.click('Create Collection Button', createCollectionButton); - await waitFor.pageToFullyLoad(); - await waitFor.visibilityOf(collectionEditorContainer); - }; - - this.clickCreateExplorationButton = async function () { - await action.click('Create Exploration Button', createExplorationButton); - await waitFor.pageToFullyLoad(); - }; - - this.clickCreateNewExplorationButton = async function () { - await action.click( - 'Create New Exploration Button', - createNewExplorationButton - ); - await waitFor.pageToFullyLoad(); - }; - - this.navigateToCollectionEditor = async function () { - await action.click('Collection Card', collectionCard); - await waitFor.pageToFullyLoad(); - }; - - this.navigateToSubscriptionDashboard = async function () { - await action.click('Subscription Tab', subscriptionTab); - await waitFor.pageToFullyLoad(); - }; - - this.editExploration = async function (explorationTitle) { - var elems = await _getExplorationElements(explorationTitle); - if (elems.length === 0) { - throw new Error( - 'Could not find exploration tile with name ' + explorationTitle - ); - } - var explorationElement = elems[0].$('.e2e-test-title-mask'); - await action.click('Exploration Element', explorationElement); - await waitFor.pageToFullyLoad(); - }; - - this.getAverageRating = async function () { - await waitFor.visibilityOf(averageRating, 'Unable to find average rating'); - return await averageRating.getText(); - }; - - this.getTotalPlays = async function () { - await waitFor.visibilityOf(totalPlays, 'Unable to find total plays'); - return await totalPlays.getText(); - }; - - this.getOpenFeedbacks = async function () { - await waitFor.visibilityOf( - openFeedbacks, - 'Unable to find open feedbacks count' - ); - return await openFeedbacks.getText(); - }; - - this.getSubscribers = async function () { - await waitFor.visibilityOf(subscribers, 'Unable to find subscribers count'); - return await subscribers.getText(); - }; - - this.getListView = async function () { - await waitFor.visibilityOf( - listViewButton, - 'Unable to find list view button' - ); - await action.click('List View Button', listViewButton); - }; - - // Returns titles of each explorations in grid view. - this.getExpSummaryTileTitles = async function () { - await waitFor.visibilityOf( - expSummaryTileTitleElement, - 'Unable to find exploration titles' - ); - var expSummaryTileTitleElements = - await expSummaryTileTitleElementsSelector(); - return expSummaryTileTitleElements; - }; - - // Returns ratings of each explorations in grid view. - this.getExpSummaryTileRatings = async function () { - await waitFor.visibilityOf( - expSummaryTileRatingElement, - 'Unable to find exploration ratings' - ); - var expSummaryTileRatingElements = - await expSummaryTileRatingElementsSelector(); - return expSummaryTileRatingElements; - }; - - // Returns open feedback count of each exploration in grid view. - this.getExpSummaryTileOpenFeedbackCount = async function () { - await waitFor.visibilityOf( - expSummaryTileFeedbackElement, - 'Unable to find exploration feedbacks' - ); - var expSummaryTileFeedbackElements = - await expSummaryTileFeedbackElementsSelector(); - return expSummaryTileFeedbackElements; - }; - - // Returns total views count of each exploration in grid view. - this.getExpSummaryTileViewsCount = async function () { - await waitFor.visibilityOf( - expSummaryTileViewsElement, - 'Unable to find exploration views' - ); - var expSummaryTileViewsElements = - await expSummaryTileViewsElementsSelector(); - return expSummaryTileViewsElements; - }; - - // Returns titles of each explorations in list view. - this.getExpSummaryRowTitles = async function () { - await waitFor.visibilityOf( - expSummaryRowTitleElement, - 'Unable to find exploration titles' - ); - var expSummaryRowTitleElements = await expSummaryRowTitleElementsSelector(); - return expSummaryRowTitleElements; - }; - - // Returns ratings of each explorations in list view. - this.getExpSummaryRowRatings = async function () { - await waitFor.visibilityOf( - expSummaryRowRatingElement, - 'Unable to find exploration ratings' - ); - var expSummaryRowRatingElements = - await expSummaryRowRatingElementsSelector(); - return expSummaryRowRatingElements; - }; - - // Returns open feedback count of each exploration in list view. - this.getExpSummaryRowOpenFeedbackCount = async function () { - await waitFor.visibilityOf( - expSummaryRowFeedbackElement, - 'Unable to find exploration feedbacks' - ); - var expSummaryRowFeedbackElements = - await expSummaryRowFeedbackElementsSelector(); - return expSummaryRowFeedbackElements; - }; - - // Returns total views count of each exploration in list view. - this.getExpSummaryRowViewsCount = async function () { - await waitFor.visibilityOf( - expSummaryRowViewsElement, - 'Unable to find exploration views' - ); - var expSummaryRowViewsElements = await expSummaryRowViewsElementsSelector(); - return expSummaryRowViewsElements; - }; - - this.expectToHaveExplorationCard = async function (explorationName) { - var explorationCards = await _getExplorationElements(explorationName); - if (explorationCards.length === 0) { - throw new Error( - 'Could not find exploration title with name ' + explorationName - ); - } - expect(explorationCards.length).toBeGreaterThanOrEqual(1); - }; -}; - -exports.CreatorDashboardPage = CreatorDashboardPage; diff --git a/core/tests/webdriverio_utils/ExplorationEditorFeedbackTab.js b/core/tests/webdriverio_utils/ExplorationEditorFeedbackTab.js deleted file mode 100644 index 4c118e1a8b94b..0000000000000 --- a/core/tests/webdriverio_utils/ExplorationEditorFeedbackTab.js +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright 2022 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview Page object for the exploration editor's feedback tab, for - * use in Protractor tests. - */ - -var action = require('./action.js'); -var waitFor = require('./waitFor.js'); - -var ExplorationEditorFeedbackTab = function () { - /* - * Interactive elements - */ - var feedbackTabRow = $('.e2e-test-oppia-feedback-tab-row'); - var explorationFeedback = $('.e2e-test-exploration-feedback'); - var feedbackBackButton = $('.e2e-test-oppia-feedback-back-button'); - var feedbackResponseTextArea = $('.e2e-test-feedback-response-textarea'); - var suggestionRowClassName = '.e2e-test-oppia-feedback-tab-row'; - var suggestionRowElement = $('.e2e-test-oppia-feedback-tab-row'); - var feedbackSubjectClassName = '.e2e-test-exploration-feedback-subject'; - var suggestionCommitMessageInput = $('.e2e-test-suggestion-commit-message'); - var suggestionReviewMessageInput = $('.e2e-test-suggestion-review-message'); - var feedbackStatusDropdown = $('.e2e-test-oppia-feedback-status-menu'); - var feedbackMessage = $('.e2e-test-exploration-feedback'); - var feedbackMessageLocator = '.e2e-test-exploration-feedback'; - var feedbackMessagesSelector = function () { - return $$('.e2e-test-exploration-feedback'); - }; - var feedbackStatusElement = $('.e2e-test-oppia-feedback-status-name'); - /* - * Buttons - */ - var acceptSuggestionButton = $('.e2e-test-exploration-accept-suggestion-btn'); - var feedbackSendResponseButton = $( - '.e2e-test-oppia-feedback-response-send-btn' - ); - var rejectSuggestionButton = $('.e2e-test-exploration-reject-suggestion-btn'); - var viewSuggestionButton = $('.e2e-test-view-suggestion-btn'); - - /* - * Workflows - */ - this.acceptSuggestion = async function (suggestionDescription) { - await waitFor.visibilityOf( - $(suggestionRowClassName), - 'Suggestion row takes too long to appear' - ); - var matchingRow = $( - `.e2e-test-exploration-feedback-subject=${suggestionDescription}` - ); - await action.click('Matching Row', matchingRow); - await action.click('View Suggestion Button', viewSuggestionButton); - await action.setValue( - 'Suggestion Commit Message Input', - suggestionCommitMessageInput, - 'Commit message' - ); - await action.click('Accept Suggestion Button', acceptSuggestionButton); - await waitFor.invisibilityOf( - acceptSuggestionButton, - 'Suggestion modal takes too long to disappear' - ); - await waitFor.pageToFullyLoad(); - }; - - this.expectToHaveFeedbackThread = async function () { - await waitFor.presenceOf( - feedbackTabRow, - 'Feedback Tab Row takes too long to appear' - ); - }; - - this.getSuggestionThreads = async function () { - var threads = []; - await waitFor.visibilityOf( - suggestionRowElement, - 'No suggestion threads are visible' - ); - var rows = await $$(suggestionRowClassName); - var rowCount = rows.length; - for (var i = 0; i < rowCount; i++) { - var row = rows[i]; - var subject = await row.$(feedbackSubjectClassName).getText(); - threads.push(subject); - } - return threads; - }; - - this.goBackToAllFeedbacks = async function () { - await action.click('Feedback Back Button', feedbackBackButton); - }; - - this.readFeedbackMessages = async function () { - var messages = []; - await waitFor.visibilityOf( - suggestionRowElement, - 'No feedback messages are visible.' - ); - var rows = await $$(suggestionRowClassName); - var rowCount = rows.length; - for (var i = 0; i < rowCount; i++) { - var row = rows[i]; - await action.click(`suggestionRow at row index ${i}`, row); - await waitFor.visibilityOf( - explorationFeedback, - 'Feedback message text is not visible' - ); - var message = await explorationFeedback.getText(); - messages.push(message); - await action.click('Feedback Back Button', feedbackBackButton); - } - return messages; - }; - - this.rejectSuggestion = async function (suggestionDescription) { - await waitFor.visibilityOf( - $(suggestionRowClassName), - 'Suggestion row takes too long to appear' - ); - var matchingRow = $( - `.e2e-test-exploration-feedback-subject=${suggestionDescription}` - ); - await action.click( - 'Matching Suggestion Row and feedback subject', - matchingRow - ); - await action.click('View Suggestion Button', viewSuggestionButton); - await action.setValue( - 'Suggestion Review Message Input', - suggestionReviewMessageInput, - 'Review message' - ); - await action.click('Reject Suggestion Button', rejectSuggestionButton); - await waitFor.invisibilityOf( - acceptSuggestionButton, - 'Suggestion modal takes too long to disappear' - ); - await waitFor.pageToFullyLoad(); - }; - - this.selectLatestFeedbackThread = async function () { - await waitFor.visibilityOf( - suggestionRowElement, - 'Suggestion row is taking too long to appear' - ); - var suggestionRowFirst = await $$(suggestionRowClassName)[0]; - await action.click('Suggestion Row First', suggestionRowFirst); - }; - - this.sendResponseToLatestFeedback = async function (feedbackResponse) { - await this.selectLatestFeedbackThread(); - await action.setValue( - 'Feedback Response Text Area', - feedbackResponseTextArea, - feedbackResponse - ); - await action.click( - 'Feedback Send Response Button', - feedbackSendResponseButton - ); - }; - - this.changeFeedbackStatus = async function ( - feedbackStatus, - feedbackResponse - ) { - await action.setValue( - 'Feedback Response Text Area', - feedbackResponseTextArea, - feedbackResponse - ); - - await waitFor.visibilityOf( - feedbackStatusDropdown, - 'Feedback Status Dropdown is not visible' - ); - await action.select( - 'Option feedback status', - feedbackStatusDropdown, - feedbackStatus - ); - - await action.click( - 'Feedback Send Response Button', - feedbackSendResponseButton - ); - }; - - this.expectNumberOfFeedbackMessagesToBe = async function (number) { - await waitFor.numberOfElementsToBe( - feedbackMessageLocator, - 'Feedback message', - number - ); - }; - - this.readFeedbackMessagesFromThread = async function () { - await waitFor.visibilityOf( - feedbackMessage, - 'Feedback message text is not visible' - ); - var feedbackMessages = await feedbackMessagesSelector(); - return feedbackMessages; - }; - - this.expectFeedbackStatusNameToBe = async function (feedbackStatus) { - await waitFor.visibilityOf( - feedbackStatusElement, - 'Feedback status is not visible.' - ); - await waitFor.textToBePresentInElement( - feedbackStatusElement, - feedbackStatus, - `Expected ${await feedbackStatusElement.getText()} ` + - `to be ${feedbackStatus}` - ); - }; -}; - -exports.ExplorationEditorFeedbackTab = ExplorationEditorFeedbackTab; diff --git a/core/tests/webdriverio_utils/ExplorationEditorHistoryTab.js b/core/tests/webdriverio_utils/ExplorationEditorHistoryTab.js deleted file mode 100644 index e7386ded23c16..0000000000000 --- a/core/tests/webdriverio_utils/ExplorationEditorHistoryTab.js +++ /dev/null @@ -1,347 +0,0 @@ -// Copyright 2022 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview Page object for the exploration editor's history tab, for - * use in Webdriverio tests. - */ - -var forms = require('./forms.js'); -var action = require('./action.js'); -var waitFor = require('./waitFor.js'); - -var ExplorationEditorHistoryTab = function () { - /* - * Interactive elements - */ - var historyGraph = $('.e2e-test-history-graph'); - var codeMirrorMergePaneElementSelector = function () { - return $$('.CodeMirror-merge .CodeMirror-merge-pane .CodeMirror-code'); - }; - var getCodeMirrorMergePaneElement = async function (paneIndex) { - await browser.waitUntil( - async function () { - var elements = await codeMirrorMergePaneElementSelector(); - return elements.length >= 2; - }, - { - timeout: 10000, - timeoutMsg: 'CodeMirror merge panes took too long to appear.', - } - ); - var elements = await codeMirrorMergePaneElementSelector(); - var firstPaneIndex = elements.length - 2; - if (paneIndex === 'left') { - return elements[firstPaneIndex]; - } - return elements[firstPaneIndex + 1]; - }; - var toastSuccessElement = $('.toast-success'); - var firstVersionDropdown = $('.e2e-test-history-version-dropdown-first'); - var secondVersionDropdown = $('.e2e-test-history-version-dropdown-second'); - var historyTableMessage = $('.e2e-test-history-table-message'); - var stateNodeBackground = function (nodeElement) { - return nodeElement.$('.e2e-test-node-background'); - }; - var stateNodeLabel = function (nodeElement) { - return nodeElement.$('.e2e-test-node-label'); - }; - - /* - * Buttons - */ - var closeStateHistoryButton = $('.e2e-test-close-history-state-modal'); - var revertVersionButton = $('.e2e-test-revert-version'); - var resetGraphButton = $('.e2e-test-reset-graph'); - var historyListOptionsSelector = function () { - return $$('.e2e-test-history-list-options'); - }; - var confirmRevertVersionButton = $('.e2e-test-confirm-revert'); - var viewMetadataHistoryButton = $('.e2e-test-view-metadata-history'); - var closeMetadataHistoryButton = $('.e2e-test-close-history-metadata-modal'); - - /* - * Display - */ - var datesCommitsWereSavedSelector = function () { - return $$('.e2e-test-history-tab-commit-date'); - }; - /* - * Workflows - */ - - /* - * This method checks if the commit dates are being displayed in - * the "List of Changes" section of the history tab. - */ - this.expectCommitDatesToBeDisplayed = async function () { - var datesCommitsWereSaved = await datesCommitsWereSavedSelector(); - var numCommitDates = datesCommitsWereSaved.length; - for (var i = 0; i < numCommitDates; i++) { - await waitFor.visibilityOf( - datesCommitsWereSaved[i], - 'Dates Commits Were Saved taking too long to appear' - ); - var date = await datesCommitsWereSaved[i].getText(); - // The dates can be of varying format - // (see getLocaleAbbreviatedDatetimeString). To play it - // safe and to keep it simple, we will just check if the - // date string contains a digit. - expect(date).toMatch(/\d/); - } - }; - - this.getHistoryGraph = function () { - return { - openStateHistory: async function (stateName) { - var stateNodes = historyGraph.$$('.e2e-test-node'); - var listOfNames = await stateNodes.map(async function (stateElement) { - await waitFor.visibilityOf( - stateNodeLabel(stateElement), - 'State Node Label taking too long to appear' - ); - return await stateNodeLabel(stateElement).getText(); - }); - var matched = false; - var stateNodes = await historyGraph.$$('.e2e-test-node'); - for (var i = 0; i < listOfNames.length; i++) { - if (listOfNames[i] === stateName) { - var stateNodeButton = stateNodes[i]; - await action.click('State Node Button', stateNodeButton); - matched = true; - } - } - if (!matched) { - throw new Error( - 'State ' + stateName + ' not found by getHistoryGraph.' - ); - } - }, - closeStateHistory: async function () { - await waitFor.elementToBeClickable( - closeStateHistoryButton, - 'Close State History button is not clickable' - ); - expect(await closeStateHistoryButton.isDisplayed()).toBe(true); - await action.click( - 'Close State History Button', - closeStateHistoryButton - ); - await waitFor.invisibilityOf( - closeStateHistoryButton, - 'Close State History button takes too long to disappear.' - ); - }, - openExplorationMetadataHistory: async function () { - await action.click( - 'View metadata changes button', - viewMetadataHistoryButton - ); - }, - closeExplorationMetadataHistory: async function () { - await waitFor.elementToBeClickable( - closeMetadataHistoryButton, - 'Close metadata history button is not clickable' - ); - expect(await closeMetadataHistoryButton.isDisplayed()).toBe(true); - await action.click( - 'Close metadata history button', - closeMetadataHistoryButton - ); - await waitFor.invisibilityOf( - closeMetadataHistoryButton, - 'Close metadata history button takes too long to disappear.' - ); - }, - deselectVersion: async function () { - await waitFor.invisibilityOf( - toastSuccessElement, - 'Toast message is taking too long to disappear after saving changes' - ); - await action.click('Reset graph button', resetGraphButton); - }, - /* - * This method selects two version's checkboxes to be compared - * Args: - * versionNumber1 (int) : history version # 1 - * versionNumber2 (int) : history version # 2 - */ - selectTwoVersions: async function (versionNumber1, versionNumber2) { - // Array starts at 0. - await action.matSelect( - 'Version Number1 Button', - firstVersionDropdown, - versionNumber1 - ); - - await action.matSelect( - 'Version Number2 Button', - secondVersionDropdown, - versionNumber2 - ); - }, - /* - * This method compares the states in the history graph using each - * state's color and label - * Args: - * expectedStates (list) : list of dicts of color and label of node - * Details of the dict - * dict key - color : color of the node - * dict key - label : label of the node (Note: if the node - * has a secondary label,the secondary - * label should appear after a space. It - * may be truncated.) - */ - expectHistoryStatesToMatch: async function (expectedStates) { - await waitFor.visibilityOf( - historyGraph, - 'History graph takes too long to be visible.' - ); - var stateNodes = historyGraph.$$('.e2e-test-node'); - var states = await stateNodes.map(async function (stateElement) { - await waitFor.visibilityOf( - await stateNodeLabel(stateElement), - 'State Node Label taking too long to appear' - ); - var label = await stateNodeLabel(stateElement).getText(); - var color = ( - await stateNodeBackground(stateElement).getCSSProperty('fill') - ).value; - return { - label: label, - color: color, - }; - }); - expect(states.length).toEqual(expectedStates.length); - // Note: we need to compare this way because the state graph is - // sometimes generated with states in different configurations. - for (var index = 0; index < states.length; index++) { - var state = states[index]; - expect(expectedStates).toContain(state); - } - }, - /* - * This method checks for the number of deleted links(red), added links - * (green) and the total numbers on the history graph - * Args: - * totalLinks (int) : total number of links - * addedLinks (int) : number of added links - * deletedLinks (int) : number of deleted links - */ - expectNumberOfLinksToMatch: async function ( - totalLinks, - addedLinks, - deletedLinks - ) { - var COLOR_ADDED = 'rgb(31,125,31)'; - var COLOR_DELETED = 'rgb(178,34,34)'; - var totalCount = 0; - var addedCount = 0; - var deletedCount = 0; - var historyGraphLink = historyGraph.$$('.e2e-test-link'); - await historyGraphLink.map(async function (link) { - var linkColor = (await link.getCSSProperty('stroke')).value; - totalCount++; - if (linkColor === COLOR_ADDED) { - addedCount++; - } else if (linkColor === COLOR_DELETED) { - deletedCount++; - } - return; - }); - expect(totalLinks).toEqual(totalCount); - expect(addedLinks).toEqual(addedCount); - expect(deletedLinks).toEqual(deletedCount); - }, - /** - * This method compares text contents of 2 version's state contents to - * provided text contents - * v1 is older state and v2 is most recent state - * Args: - * v1StateContents(dict of dict) : dicts containing state details - * of v1 - * v2StateContents(dict of dict) : dicts containing state details - * of v2 - * Details of the dict: - * dict key - line # : exact line # of text - * dict value - dicts containg info about text and whether text is - * highlighted/not highlighted - * - text: the exact string of text expected on that - * line - * - highlighted: true or false - */ - expectTextToMatch: async function (v1StateContents, v2StateContents) { - var leftCodeMirrorElement = await getCodeMirrorMergePaneElement('left'); - var rightCodeMirrorElement = - await getCodeMirrorMergePaneElement('right'); - await forms - .CodeMirrorChecker(leftCodeMirrorElement, 'first') - .expectTextToBe(v1StateContents); - await forms - .CodeMirrorChecker(rightCodeMirrorElement, 'last') - .expectTextToBe(v2StateContents); - }, - /* - * This function compares regular/highlighted text contents of 2 - * versions' state contents to provided text contents - * v1 is older state and v2 is most recent state - * Args: - * v1StateContents(dict) : dicts containing state details of v1 - * v2StateContents(dict) : dicts containing state details of v2 - * Details of the dict: - * dict key - text : extract of string of expected text - * dict key - highlighted : true or false - */ - expectTextWithHighlightingToMatch: async function ( - v1StateContents, - v2StateContents - ) { - var leftCodeMirrorElement = await getCodeMirrorMergePaneElement('left'); - var rightCodeMirrorElement = - await getCodeMirrorMergePaneElement('right'); - await forms - .CodeMirrorChecker(leftCodeMirrorElement, 'first') - .expectTextWithHighlightingToBe(v1StateContents); - await forms - .CodeMirrorChecker(rightCodeMirrorElement, 'last') - .expectTextWithHighlightingToBe(v2StateContents); - }, - }; - }; - - // This function assumes that the selected version is valid and found on the - // first page of the exploration history. - this.revertToVersion = async function (version) { - // Note: there is no 'revert' link next to the current version. - var historyListOptions = await historyListOptionsSelector(); - await action.click('History list options', historyListOptions[version - 1]); - await action.click('Revert version button', revertVersionButton); - await action.click('Confirm revert button', confirmRevertVersionButton); - }; - - this.expectRevertToVersion = async function (version) { - await waitFor.numberOfElementsToBe( - '.e2e-test-history-table-message', - 'History Table message', - 4 - ); - await waitFor.textToBePresentInElement( - historyTableMessage, - 'Reverted exploration to version ' + version, - 'Revert message takes too long to appear' - ); - }; -}; - -exports.ExplorationEditorHistoryTab = ExplorationEditorHistoryTab; diff --git a/core/tests/webdriverio_utils/ExplorationEditorImprovementsTab.js b/core/tests/webdriverio_utils/ExplorationEditorImprovementsTab.js deleted file mode 100644 index 79c7efbfe0961..0000000000000 --- a/core/tests/webdriverio_utils/ExplorationEditorImprovementsTab.js +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2022 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview End-to-end tests for the functionality of the statistics tabs - * in the exploration editor. - */ - -var waitFor = require('../webdriverio_utils/waitFor.js'); -var ExplorationEditorImprovementsTab = function () { - /* - * Interactive elements - */ - var explorationHealth = $('.e2e-test-improvements-tab-health'); - var explorationImprovementTabSelector = function () { - return $$('.e2e-test-improvements-tab'); - }; - - this.expectToBeHidden = async function () { - var explorationImprovementTab = await explorationImprovementTabSelector(); - expect(explorationImprovementTab.length).toEqual(0); - }; - - this.expectHealthyExploration = async function () { - await waitFor.visibilityOf( - explorationHealth, - 'Exploration health is taking too long to appear' - ); - expect(await explorationHealth.getText()).toEqual('HEALTHY'); - }; - - this.expectWarningExploration = async function () { - await waitFor.visibilityOf( - explorationHealth, - 'Exploration health is taking too long to appear' - ); - expect(await explorationHealth.getText()).toEqual('WARNING'); - }; - - this.expectCriticalExploration = async function () { - await waitFor.visibilityOf( - explorationHealth, - 'Exploration health is taking too long to appear' - ); - expect(await explorationHealth.getText()).toEqual('CRITICAL'); - }; -}; - -exports.ExplorationEditorImprovementsTab = ExplorationEditorImprovementsTab; diff --git a/core/tests/webdriverio_utils/ExplorationEditorMainTab.js b/core/tests/webdriverio_utils/ExplorationEditorMainTab.js deleted file mode 100644 index c6c391f082fa2..0000000000000 --- a/core/tests/webdriverio_utils/ExplorationEditorMainTab.js +++ /dev/null @@ -1,1107 +0,0 @@ -// Copyright 2022 The Oppia Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS-IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * @fileoverview Page object for the exploration editor's main tab, for use in - * WebdriverIO tests. - */ - -var forms = require('./forms.js'); -var general = require('./general.js'); -var interactions = require('../../../extensions/interactions/webdriverio.js'); -var ruleTemplates = require('../../../extensions/interactions/rule_templates.json'); -var waitFor = require('../webdriverio_utils/waitFor.js'); -var action = require('./action.js'); - -var _NEW_STATE_OPTION = 'A New Card Called...'; -var _CURRENT_STATE_OPTION = '(try again)'; - -var ExplorationEditorMainTab = function () { - /* - * Interactive elements - */ - var addOrUpdateSolutionModal = $('.e2e-test-add-or-update-solution-modal'); - var addResponseDetails = $('.e2e-test-add-response-details'); - var addResponseHeader = $('.e2e-test-add-response-modal-header'); - var answerDescription = $('.e2e-test-answer-description'); - var answerDescriptionElement = $('.e2e-test-answer-description-fragment'); - var answerTab = $('.e2e-test-answer-tab'); - var ckEditorElement = $('.e2e-test-ck-editor'); - var defaultResponseTab = $('.e2e-test-default-response-tab'); - var deleteNodeLocator = '.e2e-test-delete-node'; - var editOutcomeDestAddExplorationId = $( - '.e2e-test-add-refresher-exploration-id' - ); - var editOutcomeDestBubble = $('.e2e-test-dest-bubble'); - var editOutcomeDestForm = $('.e2e-test-dest-form'); - var editOutcomeDestDropdownOptions = $( - '.e2e-test-destination-selector-dropdown' - ); - var editorWelcomeModal = $('.e2e-test-welcome-modal'); - var explanationTextAreaElement = $('.e2e-test-explanation-textarea'); - var explorationGraph = $('.e2e-test-exploration-graph'); - var feedbackBubble = $('.e2e-test-feedback-bubble'); - var feedbackEditor = $('.e2e-test-open-feedback-editor'); - var hintTextElement = $('.e2e-test-hint-text'); - var interaction = $('.e2e-test-interaction'); - var interactionEditor = $('.e2e-test-interaction-editor'); - var interactionHtmlElement = $('.e2e-test-interaction-html'); - var updateTranslationsModalElement = $('.e2e-test-update-translations-modal'); - var leaveTranslationsAsIsButton = $('.e2e-test-leave-translations-as-is'); - var parameterElementButton = $('.e2e-test-main-html-select-selector'); - var interactionTab = function (tabId) { - return $('.e2e-test-interaction-tab-' + tabId); - }; - var interactionTile = function (interactionId) { - return $('.e2e-test-interaction-tile-' + interactionId); - }; - var itemSelectionAnswerOptions = function (optionNum) { - return $(`.e2e-test-html-item-select-option=${optionNum}`); - }; - var multipleChoiceAnswerOptions = function (optionNum) { - return $$(`.e2e-test-html-select-selector=${optionNum}`); - }; - var nodeLabelLocator = '.e2e-test-node-label'; - var openOutcomeDestEditor = $('.e2e-test-open-outcome-dest-editor'); - var openOutcomeFeedBackEditor = $('.e2e-test-open-outcome-feedback-editor'); - var postTutorialPopover = $('.shepherd-element'); - var responseBody = function (responseNum) { - return $(`.e2e-test-response-body-${responseNum}`); - }; - var responseTabElement = $('.e2e-test-response-tab'); - var ruleDetails = $('.e2e-test-rule-details'); - var stateContentDisplay = $('.e2e-test-state-content-display'); - var stateEditButton = $('.e2e-test-edit-content-pencil-button'); - var stateEditorTag = $('.e2e-test-state-content-editor'); - var stateNameContainer = $('.e2e-test-state-name-container'); - var stateNameInput = $('.e2e-test-state-name-input'); - var stateNameText = $('.e2e-test-state-name-text'); - var stateNodeLabel = function (nodeElement) { - return nodeElement.$(nodeLabelLocator); - }; - var titleElement = $('.shepherd-title'); - - /* - * Buttons - */ - var addAnswerButton = $('.e2e-test-add-answer'); - var addHintButton = $('.e2e-test-oppia-add-hint-button'); - var addInteractionButton = $('.e2e-test-open-add-interaction-modal'); - var addNewResponseButton = $('.e2e-test-add-new-response'); - var addResponseButton = $('.e2e-test-open-add-response-modal'); - var addSolutionButton = $('.e2e-test-oppia-add-solution-button'); - var answerCorrectnessToggle = $('.e2e-test-editor-correctness-toggle'); - var cancelOutcomeDestButton = $('.e2e-test-cancel-outcome-dest'); - var checkpointSelectionCheckbox = $( - '.e2e-test-checkpoint-selection-checkbox' - ); - var closeAddResponseButton = $('.e2e-test-close-add-response-modal'); - var confirmDeleteInteractionButton = $( - '.e2e-test-confirm-delete-interaction' - ); - var confirmDeleteResponseButton = $('.e2e-test-confirm-delete-response'); - var confirmDeleteStateButton = $('.e2e-test-confirm-delete-state'); - var deleteAnswerButton = $('.e2e-test-delete-answer'); - var deleteInteractionButton = $('.e2e-test-delete-interaction'); - var deleteResponseButton = $('.e2e-test-delete-response'); - var dismissWelcomeModalButton = $('.e2e-test-dismiss-welcome-modal'); - var saveAnswerButton = $('.e2e-test-save-answer'); - var saveHintButton = $('.e2e-test-save-hint'); - var saveInteractionButton = $('.e2e-test-save-interaction'); - var saveOutcomeDestButton = $('.e2e-test-save-outcome-dest'); - var saveOutcomeFeedbackButton = $('.e2e-test-save-outcome-feedback'); - var saveStateContentButton = $('.e2e-test-save-state-content'); - var startTutorialButton = $('.e2e-test-start-tutorial'); - var submitSolutionButton = $('.e2e-test-submit-solution-button'); - - /* - * Symbols - */ - var correctAnswerTickMark = $('.e2e-test-correct-tick-mark'); - - /* - * Actions - */ - - // ---- TUTORIAL ---- - - this.exitTutorial = async function () { - // Exit the welcome modal. - await action.click( - 'Dismiss Welcome Modal Button', - dismissWelcomeModalButton - ); - await waitFor.invisibilityOf( - editorWelcomeModal, - 'Editor Welcome modal takes too long to disappear' - ); - - // Otherwise, if the editor tutorial shows up, exit it. - var skipButtons = await $$('.shepherd-cancel-icon'); - if ((await skipButtons.length) === 1) { - await action.click('Skip button', skipButtons[0]); - } else if ((await skipButtons.length) !== 0) { - throw new Error("Expected to find at most one 'exit tutorial' button"); - } - }; - - this.finishTutorial = async function () { - // Finish the tutorial. - var finishTutorialButtons = await $$('.shepherd-button-primary'); - await waitFor.elementToBeClickable( - finishTutorialButtons[0], - 'Finish Tutorial Stage button is not clickable' - ); - if (finishTutorialButtons.length >= 1) { - await action.click( - 'Finish Tutorial Stage button', - finishTutorialButtons[0] - ); - } else { - throw new Error('There is more than 1 Finish button!'); - } - }; - - this.playTutorial = async function () { - var tutorialTabHeadings = [ - 'Creating in Oppia', - 'Content', - 'Interaction', - 'Responses', - 'Preview', - 'Save', - ]; - for (const HEADING of tutorialTabHeadings) { - // Use: await tutorialTabHeadings.forEach(async function(heading) { - var tutorialTabHeadingElement = $(`.shepherd-title=${HEADING}`); - await waitFor.visibilityOf( - tutorialTabHeadingElement, - 'Tutorial: ' + HEADING + ' is not visible' - ); - // Progress to the next instruction in the tutorial. - var nextTutorialStageButtons = await $$('.shepherd-button-primary'); - await waitFor.elementToBeClickable( - nextTutorialStageButtons[0], - 'Next Tutorial Stage button is not clickable' - ); - if (nextTutorialStageButtons.length >= 1) { - await action.click( - 'Next Tutorial Stage button', - nextTutorialStageButtons[0] - ); - await waitFor.invisibilityOf( - tutorialTabHeadingElement, - 'Tutorial stage takes too long to disappear' - ); - } else { - throw new Error('There is more than one Next button!'); - } - } - }; - - this.startTutorial = async function () { - await waitFor.visibilityOf( - editorWelcomeModal, - 'Editor Welcome modal takes too long to appear' - ); - await action.click('Start Tutorial button', startTutorialButton); - await waitFor.visibilityOf( - titleElement, - 'Tutorial modal takes too long to appear' - ); - }; - - // ---- RESPONSE EDITOR ---- - - /** - * This clicks the "add new response" button and then selects the rule type - * and enters its parameters, and closes the rule editor. Any number of rule - * parameters may be specified after the ruleName. - * Note that feedbackInstructions may be null (which means 'specify no - * feedback'), and only represents a single feedback element. - * @param {string} interactionId - Interaction type e.g. NumericInput - * @param {object} feedbackInstructions - A RTE object containing feedback - * or null - * @param {string} destStateName - New destination state or 'try again'/null - * @param {boolean} createNewState - True if the rule creates a new state, - * else false. - * @param {string} ruleName - The name of the rule, e.g. IsGreaterThan, must - * match with interaction type. - */ - this.addResponse = async function ( - interactionId, - feedbackInstructions, - destStateName, - createNewState, - ruleName - ) { - await action.waitForAutosave(); - // Open the "Add Response" modal if it is not already open. - await action.click('Response Editor Button', addResponseButton); - await this.setResponse.apply(null, arguments); - }; - - this.setResponse = async function ( - interactionId, - feedbackInstructions, - destStateName, - createNewState, - ruleName - ) { - // Set the rule description. - var args = [addResponseDetails, interactionId, ruleName]; - for (var i = 5; i < arguments.length; i++) { - args.push(arguments[i]); - } - await waitFor.visibilityOf( - addResponseDetails, - 'Add New Response details is not visible' - ); - await _selectRule(addResponseDetails, interactionId, ruleName); - await _setRuleParameters.apply(null, args); - // Open the feedback entry form if it is not already open. - var isVisible = await feedbackEditor.isExisting(); - if (isVisible) { - await action.click('Feedback editor', feedbackEditor); - } - - if (feedbackInstructions) { - // Set feedback contents. - await _setOutcomeFeedback(feedbackInstructions); - } - // If the destination is being changed, open the corresponding editor. - if (destStateName || destStateName !== '(try again)') { - // Set destination contents. - if (destStateName !== null) { - await _setOutcomeDest(destStateName, createNewState, null); - } - } - - // Close new response modal. - await action.click('New Response Button', addNewResponseButton); - await waitFor.invisibilityOf( - addNewResponseButton, - 'Add New Response Modal is not closed' - ); - }; - - // Rules are zero-indexed; 'default' denotes the default outcome. - // 'pop' denotes the currently opened one. - this.getResponseEditor = async function (responseNum) { - var headerElem; - if (responseNum !== 'pop') { - if (responseNum === 'default') { - headerElem = defaultResponseTab; - } else { - await waitFor.visibilityOf( - responseTabElement, - 'Response tab is not visible' - ); - var responseTab = await $$('.e2e-test-response-tab'); - headerElem = responseTab[responseNum]; - } - - var isVisible = await responseBody(responseNum).isExisting(); - if (!isVisible) { - await action.click('Response Editor Header', headerElem); - } - } else { - headerElem = addResponseHeader; - expect(await headerElem.isDisplayed()).toBe(true); - } - return { - /** - * Check for correct rule parameters. - * @param {string} [interactionId] - Interaction type. - * @param {string} [ruleName] - Appropriate rule of provided interaction. - * @param {string[]} [feedbackTextArray] - Exact feedback text to match. - */ - expectRuleToBe: async function ( - interactionId, - ruleName, - feedbackTextArray - ) { - var ruleDescription = _getRuleDescription(interactionId, ruleName); - // Replace selectors with feedbackTextArray's elements. - ruleDescription = _replaceRuleInputPlaceholders( - ruleDescription, - feedbackTextArray - ); - ruleDescription += '...'; - // Adding "..." to end of string. - expect(await action.getText('Answer Tab', answerTab)).toEqual( - ruleDescription - ); - }, - /** - * Check for correct learner's feedback. - * @param {string} [feedbackInstructionText] - Exact feedback to match. - */ - expectFeedbackInstructionToBe: async function (feedbackInstructionsText) { - // The first rule block's RTE. - var feedbackRTE = responseBody(responseNum).$('.oppia-rte-editor'); - await waitFor.visibilityOf( - feedbackRTE, - 'Feedback Rich Text Editor not showing up.' - ); - expect(await action.getText('Feedback RTE', feedbackRTE)).toEqual( - feedbackInstructionsText - ); - }, - setFeedback: async function (richTextInstructions) { - await action.waitForAutosave(); - // Begin editing feedback. - await action.click( - 'openOutcomeFeedBackEditor', - openOutcomeFeedBackEditor - ); - - // Set feedback contents. - await _setOutcomeFeedback(richTextInstructions); - - // Save feedback. - await action.click( - 'saveOutcomeFeedbackButton', - saveOutcomeFeedbackButton - ); - }, - // This saves the rule after the destination is selected. - // - destinationName: The name of the state to move to, or null to stay - // on the same state. - // - createNewState: whether the destination state is new and must be - // created at this point. - setDestination: async function ( - destinationName, - createNewState, - refresherExplorationId - ) { - // Begin editing destination. - await action.click( - 'Outcome Destination Editor Open Button', - openOutcomeDestEditor - ); - - // Set destination contents. - await _setOutcomeDest( - destinationName, - createNewState, - refresherExplorationId - ); - - // Save destination. - await action.click( - 'Outcome Destination Editor Save Button', - saveOutcomeDestButton - ); - }, - markAsCorrect: async function () { - await action.click( - 'Answer Correctness Toggle', - answerCorrectnessToggle - ); - }, - // The current state name must be at the front of the list. - expectAvailableDestinationsToBe: async function (stateNames) { - // Begin editing destination. - await action.click( - 'Outcome Destination Editor Open Button', - openOutcomeDestEditor - ); - - var expectedOptionTexts = [_CURRENT_STATE_OPTION].concat( - stateNames.slice(1) - ); - - // Create new option always at the end of the list. - expectedOptionTexts.push(_NEW_STATE_OPTION); - - var actualOptionTexts = await editOutcomeDestBubble - .$$('