diff --git a/.github/readme.md b/.github/readme.md index 02ce534fa..3bb9eba2c 100644 --- a/.github/readme.md +++ b/.github/readme.md @@ -9,61 +9,30 @@ Notes: the change may take about 20 minutes to be promoted to Zeva development environment on Openshift -## Add the build-on-dev to the end of pull request's title - -The "Build PR on Dev" pipeline will be triggered when it identified pull request's title ends with "build-on-dev" - -# Production release +# Pipelines -## Pre-production release +## Primary Pipelines -- Update the description of the tracking pull request -- Verify the changes made during the previous post production release +- dev-ci.yaml: Build the tracking pull request and delpoy on Dev +- test-ci.yaml: Tag the running images on Dev to Test and delpoy on Test +- prod-ci.yaml: Tag the running images on Test to Test and delpoy on Prod -## Production release +## Other Pipelines -- Manually trigger the pipeline release-build.yaml (Release Build 1.49.0) +- cleanup-cron-workflow-runs.yaml (Scheduled cleanup old workflow runs): a cron job running periodically to cleanup old workflow runs +- cleanup-workflow-runs.yaml (Cleanup old workflow runs): manually cleanup the workflow runs +- emergency-release-build.yaml (Emergency Release Build 1.47.1): the pipeline built for emergency release 1.47.1 -## Post production release +# Post production release -### Merge the tracking pull request and create the new release branch +## Merge the tracking pull request and create the new release branch -- Squash merge the tracking pull request to master +- Squash and merge the tracking pull request to master - Create the release on GitHub from master branch - Create the new release branch from master branch (this is done automatically by pipeline create-release.yaml) - Change the new release branch as the default branch in the repo and update the branch protection rules https://github.com/bcgov/zeva/settings/branches -### Updates for the new release branch +## Updates for the new release branch -- dev-build.yaml - - on -> push -> branches - - env -> PR_NUMBER - - env -> VERSION - - jobs -> call-unit-test -> with -> pr-number -- Update frontend/package.json version and create the new tracking pull request -- Update release-build.yaml - - name - - env -> PR_NUMBER - - env -> VERSION - - jobs -> call-unit-test -> with -> pr-numb -- Create the trackings pull request to merge the new release branch to master. Update the abour PR_NUMBER after the trackings pull request is created. - -# Pipelines - -## Primary Pipelines - -- build-on-dev.yaml (Build PR on Dev): Build pull request if the string build-on-dev is appended at the end of pull request title -- dev-build.yaml (Dev Build 1.49.0): Every commit on the current release branch is automatically released to Dev. -- release-build.yaml (Release Build 1.49.0): This is a manually managed pipeline. It needs to be triggered manually to build the current release branch and deploy on Test and further on Prod. -- create-release.yaml (Create Release after merging to master): Automatically tag and create the release after merging release branch to master. The description of the tracking pull request becomes release notes. - -## Other Pipelines - -- cleanup-cron-workflow-runs.yaml (Scheduled cleanup old workflow runs): a cron job running periodically to cleanup old workflow runs -- cleanup-workflow-runs.yaml (Cleanup old workflow runs): manually cleanup the workflow runs -- emergency-release-build.yaml (Emergency Release Build 1.47.1): the pipeline built for emergency release 1.47.1 -- pr-build-template.yaml (PR Build Template): a pipeline template for pull request build -- pr-lable.yaml (Label PR): ignore this one for now, it is automatically triggered after the pull request is merged or closed -- pr-teardown.yaml (Teardown PR on Dev): remove the deployed pull request on Dev -- release-build.yaml (Release Build 1.49.0): a pipeline to build release branch -- unit-test-template.yaml (Unit Test Template): a pipeline template for unit test +- Update frontend/package.json version in the new release branch +- Create the new tracking pull request to merge the nre release branch to master diff --git a/.github/workflows/dev-ci.yaml b/.github/workflows/dev-ci.yaml index 0b1628ae3..e8e4e627f 100644 --- a/.github/workflows/dev-ci.yaml +++ b/.github/workflows/dev-ci.yaml @@ -17,9 +17,37 @@ concurrency: cancel-in-progress: true jobs: + + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client + verify-pr: name: Verify pull request title started with Tracking runs-on: ubuntu-latest + needs: [install-oc] steps: - name: Check PR Title id: check_pr_title @@ -92,6 +120,12 @@ jobs: - name: Check out repository uses: actions/checkout@v4.1.1 + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: @@ -130,6 +164,12 @@ jobs: - name: Check out repository uses: actions/checkout@v4.1.1 + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: @@ -186,6 +226,12 @@ jobs: git commit -m "Update the image tag to ${{ env.VERSION }}-${{ env.PRE_RELEASE }} on Dev" git push + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml index fd607125e..57cd71801 100644 --- a/.github/workflows/pr-build.yaml +++ b/.github/workflows/pr-build.yaml @@ -3,6 +3,8 @@ name: PR Build on Dev on: pull_request: types: [labeled, synchronize] + branches: + - release-1.65.0 paths: - frontend/** - backend/** @@ -19,17 +21,58 @@ concurrency: cancel-in-progress: true jobs: + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client + get-version: if: > (github.event.action == 'labeled' && github.event.label.name == 'build' && github.event.pull_request.base.ref == github.event.repository.default_branch) || (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'build') && github.event.pull_request.base.ref == github.event.repository.default_branch) name: Retrieve version runs-on: ubuntu-latest + needs: [install-oc] outputs: output1: ${{ steps.get-version.outputs.VERSION }} steps: + - name: show + run: | + echo ${{ env.GIT_URL }} + echo ${{ env.TOOLS_NAMESPACE }} + echo ${{ env.DEV_NAMESPACE }} + echo ${{ env.PR_NUMBER }} + echo ${{ env.GIT_REF }} + + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: @@ -68,6 +111,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: @@ -108,6 +157,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: @@ -165,6 +220,12 @@ jobs: replace: "${{ env.PR_NUMBER }}" include: "zeva/values-dev-pr.yaml" regex: false + + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 diff --git a/.github/workflows/pr-teardown.yaml b/.github/workflows/pr-teardown.yaml index 2c0b82381..238d671a1 100644 --- a/.github/workflows/pr-teardown.yaml +++ b/.github/workflows/pr-teardown.yaml @@ -13,6 +13,32 @@ concurrency: cancel-in-progress: true jobs: + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client + teardown: if: > (github.event.action == 'unlabeled' && github.event.label.name == 'build') || @@ -20,8 +46,15 @@ jobs: name: PR Teardown runs-on: ubuntu-latest timeout-minutes: 60 + needs: [install-oc] steps: + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: diff --git a/.github/workflows/prod-ci.yaml b/.github/workflows/prod-ci.yaml index 72fa32f26..5d3d70a2e 100644 --- a/.github/workflows/prod-ci.yaml +++ b/.github/workflows/prod-ci.yaml @@ -15,14 +15,47 @@ concurrency: cancel-in-progress: true jobs: + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client + get-build-suffix: name: Find Test deployment build suffix runs-on: ubuntu-latest + needs: [install-oc] outputs: output1: ${{ steps.get-build-suffix.outputs.BUILD_SUFFIX }} steps: + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: @@ -61,6 +94,12 @@ jobs: minimum-approvals: 2 issue-title: "ZEVA ${{ env.BUILD_SUFFIX }} Prod Deployment" + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: diff --git a/.github/workflows/test-ci.yaml b/.github/workflows/test-ci.yaml index 2a88c727a..20ca8b016 100644 --- a/.github/workflows/test-ci.yaml +++ b/.github/workflows/test-ci.yaml @@ -15,14 +15,47 @@ concurrency: cancel-in-progress: true jobs: + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client + get-build-suffix: name: Find Dev deployment build suffix runs-on: ubuntu-latest + needs: [install-oc] outputs: output1: ${{ steps.get-build-suffix.outputs.BUILD_SUFFIX }} steps: + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: @@ -85,6 +118,12 @@ jobs: git commit -m "Update the image tag to ${{ env.BUILD_SUFFIX }} on Zeva Test Environment" git push + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: diff --git a/backend/api/fixtures/test/0003_add_bceid_users.py b/backend/api/fixtures/test/0003_add_bceid_users.py index 40c08da32..4bab4c530 100644 --- a/backend/api/fixtures/test/0003_add_bceid_users.py +++ b/backend/api/fixtures/test/0003_add_bceid_users.py @@ -72,18 +72,28 @@ def check_run_preconditions(self): @transaction.atomic def run(self): - - organizations = Organization.objects.filter( + organizations = list(Organization.objects.filter( is_government=False, is_active=True - ) + )) - users_added = 0 + num_users = len(self.list_of_users) + if len(organizations) < num_users: + num_to_create = num_users - len(organizations) + for i in range(num_to_create): + new_org = Organization.objects.create( + name=f"Random Organization {i}-{random.randint(1, 10000)}", + is_government=False, + is_active=True, + ) + organizations.append(new_org) - for user in self.list_of_users: - organization = random.choice(organizations) + random.shuffle(organizations) + users_added = 0 - (_, created) = UserProfile.objects.get_or_create( + for i, user in enumerate(self.list_of_users): + organization = organizations[i] + _, created = UserProfile.objects.get_or_create( first_name=user.get("first_name"), last_name=user.get("last_name"), organization=organization, @@ -100,5 +110,4 @@ def run(self): print("Added {} BCEID Users.".format(users_added)) - script_class = AddBCEIDUsers diff --git a/backend/api/management/commands/reset_database.py b/backend/api/management/commands/reset_database.py new file mode 100644 index 000000000..9a45e148c --- /dev/null +++ b/backend/api/management/commands/reset_database.py @@ -0,0 +1,228 @@ +from django.core.management import BaseCommand +from django.db import connection +from django.db import transaction +from django.conf import settings +from api.models.user_profile import UserProfile +from api.models.organization import Organization + +class Command(BaseCommand): + help = "Reset the database to an almost empty state " \ + "while leaving behind essential data such as some organizations, users, and login information." + + """ + # Permanent Data Tables (not to be reset): + address_typeauth_group + auth_* + compliance_ratio + credit_class_code + credit_transaction_type + django_* + fixture_migrations + model_year + notification + signing_authority_assertion + permission + role + role_permission + vehicle_class_code + vehicle_zev_type + weight_class_code + + # Optional Tables (may be partially reset depending on the options): + organization + organization_address + user_profile + user_role + """ + + tables_to_be_reset = [ + # credit_agreement tables + "credit_agreement_comment", + "credit_agreement_content", + "credit_agreement_credit_transaction", + "credit_agreement_file_attachment", + "credit_agreement_history", + "credit_agreement", + + # credit_transfer tables + "credit_transfer_comment", + "credit_transfer_content", + "credit_transfer_credit_transaction", + "credit_transfer_history", + "credit_transfer", + + # icbc tables + "icbc_registration_data", + "icbc_snapshot_data", + "icbc_upload_date", + "icbc_vehicle", + + # model_year_report tables + "model_year_report_address", + "model_year_report_adjustment", + "model_year_report_assessment", + "model_year_report_assessment_comment", + "model_year_report_assessment_descriptions", + "model_year_report_compliance_obligation", + "model_year_report_confirmation", + "model_year_report_credit_offset", + "model_year_report_credit_transaction", + "model_year_report_history", + "model_year_report_ldv_sales", + "model_year_report_make", + "model_year_report_vehicle", + "model_year_report", + + # organization tables + "organization_deficits", + "organization_ldv_sales", + + # sales_forecast tables + "sales_forecast_record", + "sales_forecast", + + # sales_submission tables + "sales_submission_comment", + "sales_submission_content", + "sales_submission_content_reason", + "sales_submission_credit_transaction", + "sales_submission_evidence", + "sales_submission_history", + "sales_submission", + + # supplemental_report tables + "supplemental_report_assessment", + "supplemental_report_assessment_comment", + "supplemental_report_comment", + "supplemental_report_credit_activity", + "supplemental_report_file_attachment", + "supplemental_report_history", + "supplemental_report_sales", + "supplemental_report_supplier_information", + "supplemental_report", + + # vehicle tables + "vehicle_change_history", + "vehicle_comment", + "vehicle_file_attachment", + "vehicle", + + # other tables + "credit_transaction", + "notification_subscription", + "record_of_sale", + "signing_authority_confirmation", + "user_creation_request", + ] + + def add_arguments(self, parser): + parser.add_argument( + "--inactive-users", + action = "store_true", + help = "also remove all inactive user profiles." + ) + parser.add_argument( + "--orgs-without-users", + action = "store_true", + help = "also remove all organizations without any associated users. " + "When used with --inactive-users, the inactive users will be removed first." + ) + parser.add_argument( + "--users-without-orgs", + action = "store_true", + help = "also remove all user profiles without an organization." + ) + parser.add_argument( + "--non-gov", + action = "store_true", + help = "also remove all vehicle suppliers (non-government organizations) and non-government users." + ) + + def handle(self, *args, **options): + if settings.ENV_NAME == "prod": + print("ERROR: This command is not allowed in the production environment.") + return + + is_removing_inactive_users = options["inactive_users"] + is_removing_users_without_orgs = options["users_without_orgs"] + is_removing_orgs_without_users = options["orgs_without_users"] + is_removing_non_gov = options["non_gov"] + is_preserving_users_and_orgs = not ( + is_removing_inactive_users or + is_removing_users_without_orgs or + is_removing_orgs_without_users or + is_removing_non_gov + ) + + print("WARNING: This command will reset the database to an almost empty state.", end=" ") + if is_preserving_users_and_orgs: + print("All user profiles and organizations will be preserved.") + else: + print("Some user profiles and organizations will be preserved EXCEPT the following:") + if is_removing_inactive_users: + print("- Inactive users will also be removed.") + if is_removing_users_without_orgs: + print("- User profiles without an organization will also be removed.") + if is_removing_orgs_without_users: + print("- Organizations without any associated users will also be removed.") + if is_removing_non_gov: + print("- Vehicle suppliers (non-government organizations) and non-government user profiles will also be removed.") + + confirmation = input("Are you sure you want to proceed? (y/N): ") + if confirmation.lower() == "y": + try: + self.deleteData( + is_removing_inactive_users, + is_removing_users_without_orgs, + is_removing_orgs_without_users, + is_removing_non_gov + ) + print("Completed resetting the database.") + except Exception as e: + print("Error: " + str(e)) + print("Changes are rolled back.") + else: + print("Operation cancelled.") + + @transaction.atomic + def deleteData(self, is_removing_inactive_users, is_removing_users_without_orgs, is_removing_orgs_without_users, is_removing_non_gov_orgs): + self.resetTables(self.tables_to_be_reset) + if is_removing_inactive_users: + self.deleteInactiveUsers() + if is_removing_users_without_orgs: + self.deleteUsersWithoutOrgs() + if is_removing_orgs_without_users: + self.deleteOrgsWithoutUsers() + if is_removing_non_gov_orgs: + self.deleteNonGovOrgs() + + def resetTables(self, tables): + with connection.cursor() as cursor: + for table_name in tables: + print("Deleting all data from " + table_name + "...") + cursor.execute("DELETE FROM %s" % table_name) + + def deleteInactiveUsers(self): + inactive_user_profiles = UserProfile.objects.filter(is_active=False) + print("Deleting " + str(len(inactive_user_profiles)) + " inactive user profiles...") + for user_profile in inactive_user_profiles: + user_profile.delete() + + def deleteUsersWithoutOrgs(self): + users_without_orgs = UserProfile.objects.filter(organization__isnull=True) + print("Deleting " + str(len(users_without_orgs)) + " user profiles without an organization...") + for user_profile in users_without_orgs: + user_profile.delete() + + def deleteOrgsWithoutUsers(self): + organizations_without_users = Organization.objects.filter(userprofile__isnull=True) + print("Deleting " + str(len(organizations_without_users)) + " organizations without associated users...") + for organization in organizations_without_users: + organization.delete() + + def deleteNonGovOrgs(self): + non_gov_organizations = Organization.objects.filter(is_government=False) + print("Deleting " + str(len(non_gov_organizations)) + " non-government organizations...") + for organization in non_gov_organizations: + organization.delete() + self.deleteUsersWithoutOrgs() diff --git a/backend/api/migrations/0001_squashed_0138_alter_accountbalance_options.py b/backend/api/migrations/0001_squashed_0138_alter_accountbalance_options.py index d6de4f286..17eb179c4 100644 --- a/backend/api/migrations/0001_squashed_0138_alter_accountbalance_options.py +++ b/backend/api/migrations/0001_squashed_0138_alter_accountbalance_options.py @@ -1810,7 +1810,7 @@ class Migration(migrations.Migration): ], options={ 'db_table': 'account_balance', - 'managed': False, + 'managed': True, }, bases=(models.Model, db_comments.model_mixins.DBComments), ), diff --git a/backend/api/migrations/0010_auto_20241209_1505.py b/backend/api/migrations/0010_auto_20241209_1505.py new file mode 100644 index 000000000..0872f35c3 --- /dev/null +++ b/backend/api/migrations/0010_auto_20241209_1505.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.25 on 2024-12-09 23:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0009_auto_20241003_1425'), + ] + + operations = [ + migrations.AlterField( + model_name='modelyearreportassessmentcomment', + name='comment', + field=models.TextField(blank=True, db_column='assessment_comment', null=True), + ), + ] diff --git a/backend/api/migrations/0011_auto_20250115_1420.py b/backend/api/migrations/0011_auto_20250115_1420.py new file mode 100644 index 000000000..c7a2a82bf --- /dev/null +++ b/backend/api/migrations/0011_auto_20250115_1420.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2025-01-15 22:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0010_auto_20241209_1505'), + ] + + operations = [ + migrations.AddField( + model_name='organizationldvsales', + name='is_supplied', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/api/migrations/0012_auto_20250131_1252.py b/backend/api/migrations/0012_auto_20250131_1252.py new file mode 100644 index 000000000..d088a5c9f --- /dev/null +++ b/backend/api/migrations/0012_auto_20250131_1252.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.25 on 2025-01-31 20:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0011_auto_20250115_1420'), + ] + + operations = [ + migrations.AlterModelOptions( + name='accountbalance', + options={'managed': True}, + ), + migrations.DeleteModel( + name='AccountBalance', + ), + ] diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py index 71afa4880..f7598ee9e 100644 --- a/backend/api/models/__init__.py +++ b/backend/api/models/__init__.py @@ -15,7 +15,6 @@ from . import vehicle_class from . import fixture_migration from . import vehicle_attachment -from . import account_balance from . import weight_class from . import sales_submission_content from . import address_type diff --git a/backend/api/models/account_balance.py b/backend/api/models/account_balance.py deleted file mode 100644 index bb9d4bbe5..000000000 --- a/backend/api/models/account_balance.py +++ /dev/null @@ -1,43 +0,0 @@ -from django.db import models - -from api.models.mixins.effective_dates import EffectiveDates -from auditable.models import Auditable - -# to be deleted; you will not be able to import this module, -# and any changes to fields will not be picked up when you run makemigrations -class AccountBalance(Auditable, EffectiveDates): - balance = models.DecimalField( - db_comment="balance", - max_digits=20, - decimal_places=2, - default=0, - null=False - ) - - credit_class = models.ForeignKey( - 'CreditClass', - related_name="+", - on_delete=models.PROTECT, - null=False - ) - - organization = models.ForeignKey( - 'Organization', - related_name="+", - on_delete=models.PROTECT, - null=False - ) - - credit_transaction = models.ForeignKey( - 'CreditTransaction', - related_name="+", - on_delete=models.PROTECT, - null=True - ) - - class Meta: - db_table = "account_balance" - managed = False - - db_table_comment = "account balances. A and B class credits" \ - "are kept as separate records." diff --git a/backend/api/models/model_year_report_assessment_comment.py b/backend/api/models/model_year_report_assessment_comment.py index 9cf34e11a..f0cd1ef8a 100644 --- a/backend/api/models/model_year_report_assessment_comment.py +++ b/backend/api/models/model_year_report_assessment_comment.py @@ -20,8 +20,7 @@ class ModelYearReportAssessmentComment(Auditable): default=False, db_comment="determines if comment is meant for director" ) - comment = models.CharField( - max_length=4000, + comment = models.TextField( blank=True, null=True, db_column='assessment_comment', diff --git a/backend/api/models/organization.py b/backend/api/models/organization.py index 4ddbbe4f2..22f4cc540 100644 --- a/backend/api/models/organization.py +++ b/backend/api/models/organization.py @@ -122,13 +122,14 @@ def ldv_sales(self): return sales def get_ldv_sales(self, year): + is_supplied = False if year < 2024 else True sales = self.ldv_sales.filter( model_year__name__in=[ str(year - 1), str(year - 2), str(year - 3) ] - ) + ).filter(is_supplied=is_supplied) return sales def get_avg_ldv_sales(self, year=None): @@ -137,15 +138,18 @@ def get_avg_ldv_sales(self, year=None): if date.today().month < 10: year -= 1 + is_supplied = False if year < 2024 else True sales = self.ldv_sales.filter( model_year__name__lte=year - ).values_list( + ).filter(is_supplied=is_supplied).values_list( 'ldv_sales', flat=True )[:3] if sales.count() < 3: - sales = self.ldv_sales.filter(model_year__name=year).values_list( + sales = self.ldv_sales.filter(model_year__name=year).filter( + is_supplied=is_supplied + ).values_list( 'ldv_sales', flat=True )[:1] diff --git a/backend/api/models/organization_ldv_sales.py b/backend/api/models/organization_ldv_sales.py index 908e426f4..5c0f4f7f8 100644 --- a/backend/api/models/organization_ldv_sales.py +++ b/backend/api/models/organization_ldv_sales.py @@ -27,6 +27,9 @@ class OrganizationLDVSales(Auditable): on_delete=models.PROTECT, null=False ) + is_supplied = models.BooleanField( + default=False + ) class Meta: db_table = "organization_ldv_sales" diff --git a/backend/api/serializers/credit_agreement.py b/backend/api/serializers/credit_agreement.py index 8564cd2e8..cf62fbe96 100644 --- a/backend/api/serializers/credit_agreement.py +++ b/backend/api/serializers/credit_agreement.py @@ -30,9 +30,15 @@ class CreditAgreementSerializer(UserSerializerMixin): def get_comments(self, obj): request = self.context.get('request') - agreement_comment = CreditAgreementComment.objects.filter( + if request.user.is_government: + agreement_comment = CreditAgreementComment.objects.filter( credit_agreement=obj ).order_by('-create_timestamp') + else: + agreement_comment = CreditAgreementComment.objects.filter( + credit_agreement=obj, + to_director=False + ).order_by('-create_timestamp') if agreement_comment.exists(): serializer = CreditAgreementCommentSerializer( diff --git a/backend/api/serializers/organization_ldv_sales.py b/backend/api/serializers/organization_ldv_sales.py index d8da291cd..1d223eaa1 100644 --- a/backend/api/serializers/organization_ldv_sales.py +++ b/backend/api/serializers/organization_ldv_sales.py @@ -19,10 +19,12 @@ def save(self): organization = self.context.get('organization') model_year = self.validated_data.get('model_year') ldv_sales = self.validated_data.get('ldv_sales') + is_supplied = self.validated_data.get('is_supplied') organization_ldv_sale = OrganizationLDVSales.objects.update_or_create( model_year_id=model_year.id, organization_id=organization.id, + is_supplied=is_supplied, defaults={ 'ldv_sales': ldv_sales, 'create_user': request.user.username, @@ -35,5 +37,5 @@ def save(self): class Meta: model = OrganizationLDVSales fields = ( - 'id', 'ldv_sales', 'model_year' + 'id', 'ldv_sales', 'model_year', 'is_supplied' ) diff --git a/backend/api/services/model_year_report.py b/backend/api/services/model_year_report.py index 52d49c923..fa11e81a8 100644 --- a/backend/api/services/model_year_report.py +++ b/backend/api/services/model_year_report.py @@ -205,12 +205,15 @@ def adjust_credits_reassessment(id, request): organization_id = model_year_report.organization.id ldv_sales = reassessment.ldv_sales if ldv_sales: + model_year_int = int(model_year_report.model_year.name) + is_supplied = False if model_year_int < 2024 else True OrganizationLDVSales.objects.update_or_create( organization_id=organization_id, model_year_id=model_year_id, + is_supplied=is_supplied, defaults={ - 'ldv_sales': ldv_sales, - 'update_user': request.user.username + 'ldv_sales': ldv_sales, + 'update_user': request.user.username } ) @@ -284,9 +287,11 @@ def adjust_credits(id, request): model_year_report_id=id ).order_by('-update_timestamp').first() + is_supplied = False if int(model_year) < 2024 else True OrganizationLDVSales.objects.update_or_create( organization_id=organization_id, model_year_id=model_year_id, + is_supplied=is_supplied, defaults={ 'ldv_sales': ldv_sales, 'update_user': request.user.username @@ -363,6 +368,8 @@ def adjust_credits(id, request): credit_transaction_id=added_transaction.id ) + OrganizationDeficits.objects.filter(organization_id=organization_id).delete() + deficits = ModelYearReportComplianceObligation.objects.filter( model_year_report_id=id, category='CreditDeficit', @@ -370,41 +377,30 @@ def adjust_credits(id, request): ) for deficit in deficits: + deficit_model_year_id = deficit.model_year_id if deficit.credit_a_value > 0: OrganizationDeficits.objects.update_or_create( credit_class=credit_class_a, organization_id=organization_id, - model_year_id=model_year_id, + model_year_id=deficit_model_year_id, defaults={ 'credit_value': deficit.credit_a_value, 'create_user': request.user.username, 'update_user': request.user.username } ) - else: - OrganizationDeficits.objects.filter( - credit_class=credit_class_a, - organization_id=organization_id, - model_year_id=model_year_id - ).delete() - if deficit.credit_b_value > 0: OrganizationDeficits.objects.update_or_create( credit_class=credit_class_b, organization_id=organization_id, - model_year_id=model_year_id, + model_year_id=deficit_model_year_id, defaults={ 'credit_value': deficit.credit_b_value, 'create_user': request.user.username, 'update_user': request.user.username } ) - else: - OrganizationDeficits.objects.filter( - credit_class=credit_class_b, - organization_id=organization_id, - model_year_id=model_year_id - ).delete() + def check_validation_status_change(old_status, updated_model_year_report): new_status = updated_model_year_report.validation_status diff --git a/backend/api/services/sales_spreadsheet.py b/backend/api/services/sales_spreadsheet.py index fa95175c6..6342496e2 100644 --- a/backend/api/services/sales_spreadsheet.py +++ b/backend/api/services/sales_spreadsheet.py @@ -347,18 +347,25 @@ def validate_spreadsheet(data, user_organization=None, skip_authorization=False) ) row = start_row + vin_dict = {} + duplicates = {} while row < min((MAX_READ_ROWS + start_row), sheet.nrows): row_contents = sheet.row(row) model_year = str(row_contents[0].value).strip() make = str(row_contents[1].value).strip() model_name = str(row_contents[2].value).strip() - vin = str(row_contents[3].value) + vin = str(row_contents[3].value).strip() date = str(row_contents[4].value).strip() row_contains_content = False if len(model_year) > 0 or len(make) > 0 or len(model_name) > 0 or \ len(date) > 0: row_contains_content = True + if row_contains_content: + if vin in vin_dict: + vin_dict[vin].append(row + 1) + else: + vin_dict[vin] = [row + 1] if row_contains_content and row_contents[4].ctype != xlrd.XL_CELL_DATE: raise ValidationError( @@ -371,7 +378,14 @@ def validate_spreadsheet(data, user_organization=None, skip_authorization=False) 'the row and try again.' ) row += 1 - + duplicates = {vin: rows for vin, rows in vin_dict.items() if len(rows) > 1} + if duplicates: + error_message = 'Spreadsheet contains duplicate VINs.|' + for vin, rows in duplicates.items(): + row_list = ', '.join(map(str, rows)) + error_message += f'VIN {vin}: rows {row_list}|' + error_message += 'Please make changes to the spreadsheet and try again.' + raise ValidationError(error_message) return True diff --git a/backend/api/tests/test_credit_agreements.py b/backend/api/tests/test_credit_agreements.py new file mode 100644 index 000000000..ea5897664 --- /dev/null +++ b/backend/api/tests/test_credit_agreements.py @@ -0,0 +1,221 @@ +import json +from rest_framework.serializers import ValidationError +from django.utils import timezone +from django.db import transaction +from api.services.minio import minio_put_object +from .base_test_case import BaseTestCase +from ..models.credit_agreement import CreditAgreement +from ..models.credit_class import CreditClass +from ..models.model_year import ModelYear +from ..models.weight_class import WeightClass +from ..models.credit_transaction_type import CreditTransactionType +from ..models.organization import Organization +from ..models.credit_agreement_statuses import CreditAgreementStatuses +from ..models.credit_agreement_comment import CreditAgreementComment +from ..models.credit_agreement_transaction_types import CreditAgreementTransactionTypes +from unittest.mock import patch +from rest_framework import status +from django.urls import reverse + +class TestAgreements(BaseTestCase): + def setUp(self): + super().setUp() + user = self.users['RTAN_BCEID'] + self.org = user.organization + + self.credit_agreement = CreditAgreement.objects.create( + organization=self.org, + transaction_type=CreditAgreementTransactionTypes.INITIATIVE_AGREEMENT, + status=CreditAgreementStatuses.RECOMMENDED, + ) + + self.comment_bceid = CreditAgreementComment.objects.create( + credit_agreement=self.credit_agreement, + to_director=False, + comment='test', + create_user='RTAN' + ) + + self.comment_idir = CreditAgreementComment.objects.create( + credit_agreement=self.credit_agreement, + to_director=True, + comment='test to director', + create_user='test user' + ) + + def test_list_credit_agreements(self): + response = self.clients['RTAN'].get('/api/credit-agreements') + self.assertEqual(response.status_code, status.HTTP_200_OK) + ##idir should see all agreements except deleted + idir_agreements= CreditAgreement.objects.exclude( + status=CreditAgreementStatuses.DELETED + ) + self.assertEqual(len(response.data), idir_agreements.count()) + + bceid_response = self.clients['RTAN_BCEID'].get('/api/credit-agreements') + self.assertEqual(bceid_response.status_code, status.HTTP_200_OK) + ##bceid should see only agreements issued to their organization + filtered_agreements = CreditAgreement.objects.filter( + organization=self.org, + status=CreditAgreementStatuses.ISSUED, + ) + self.assertEqual(len(bceid_response.data), filtered_agreements.count()) + + + def test_comment_save(self): + data = { + 'comment': 'New comment', + 'director': False, + } + response = self.clients['RTAN'].patch('/api/credit-agreements/{}/comment_save' + .format(self.credit_agreement.id), + data , + content_type='application/json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(CreditAgreementComment.objects + .filter(comment='New comment') + .exists()) + + def test_get_agreement_details(self): + response_bceid = self.clients['RTAN_BCEID'].get( + '/api/credit-agreements/' + str(self.credit_agreement.id)) + #bceid users cannot see unissued agreements + self.assertEqual(response_bceid.status_code, status.HTTP_404_NOT_FOUND) + #idir users can get the details + response_idir = self.clients['RTAN'].get( + '/api/credit-agreements/' + str(self.credit_agreement.id)) + self.assertEqual(response_idir.status_code, status.HTTP_200_OK) + comments = response_idir.data['comments'] + self.assertEquals(len(comments), 2) + + def test_update_agreement_status(self): + update_response = self.clients['RTAN'].patch( + '/api/credit-agreements/{}'.format(self.credit_agreement.id), + {'validationStatus': 'ISSUED'}, content_type='application/json') + self.assertEqual(update_response.status_code, status.HTTP_200_OK) + #bceid users can see non director comments if the status is issued + response_bceid = self.clients['RTAN_BCEID'].get( + '/api/credit-agreements/' + str(self.credit_agreement.id)) + self.assertEqual(len(response_bceid.data['comments']), + len(CreditAgreementComment.objects.filter( + to_director='False', + credit_agreement_id=self.credit_agreement.id))) + + + def test_update_comment(self): + ##idir can update their own comment + data = { + 'comment_id': self.comment_bceid.id, + 'comment_text': 'Updated comment to bceid', + } + response = self.clients['RTAN'].patch( + '/api/credit-agreements/{}/update_comment' + .format(self.credit_agreement.id), data, content_type='application/json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.comment_bceid.refresh_from_db() + self.assertEqual(self.comment_bceid.comment, 'Updated comment to bceid') + + ##idir cannot update someone elses comment + data = { + 'comment_id': self.comment_idir.id, + 'comment_text': 'Updated comment to idir', + } + response = self.clients['RTAN'].patch( + '/api/credit-agreements/{}/update_comment' + .format(self.credit_agreement.id), data, content_type='application/json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.comment_bceid.refresh_from_db() + self.assertNotEqual(self.comment_bceid.comment, 'Updated comment to idir') + + ##bceid cannot update a comment + response = self.clients['RTAN_BCEID'].patch( + '/api/credit-agreements/{}/update_comment' + .format(self.credit_agreement.id), data, content_type='application/json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.comment_bceid.refresh_from_db() + self.assertNotEqual(self.comment_bceid.comment, 'Updated comment to idir') + + def test_delete_comment(self): + #idir user can delete their own comment + data = { + 'comment_id': self.comment_bceid.id, + } + response = self.clients['RTAN'].patch( + '/api/credit-agreements/{}/delete_comment' + .format(self.credit_agreement.id), + data, + content_type='application/json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(CreditAgreementComment.objects + .filter(id=self.comment_bceid.id) + .exists()) + + #idir cannot delete someone elses comment + data = { + 'comment_id': self.comment_idir.id, + } + response = self.clients['RTAN'].patch( + '/api/credit-agreements/{}/delete_comment' + .format(self.credit_agreement.id), + data, + content_type='application/json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(CreditAgreementComment.objects + .filter(id=self.comment_idir.id) + .exists()) + + #bceid cannot delete someone elses comment + response = self.clients['RTAN_BCEID'].patch( + '/api/credit-agreements/{}/delete_comment' + .format(self.credit_agreement.id), + data, + content_type='application/json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(CreditAgreementComment.objects + .filter(id=self.comment_idir.id) + .exists()) + + def test_no_comments_on_agreement(self): + CreditAgreementComment.objects.all().delete() + response = self.clients['RTAN'].get( + '/api/credit-agreements/' + str(self.credit_agreement.id)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data['comments']) + + def test_list_no_credit_agreements(self): + CreditAgreementComment.objects.all().delete() + CreditAgreement.objects.all().delete() + response = self.clients['RTAN'].get('/api/credit-agreements') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 0) + + def test_get_invalid_agreement(self): + response = self.clients['RTAN'].get( + '/api/credit-agreements/testagreementdoesntexist') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_invalid_comment(self): + data = { + 'comment_id': 999999, # Nonexistent comment ID + 'comment_text': 'Should fail', + } + response = self.clients['RTAN'].patch( + '/api/credit-agreements/{}/update_comment' + .format(self.credit_agreement.id), data, content_type='application/json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @patch('api.viewsets.credit_agreement.minio_put_object') + def test_minio_url(self, mock_minio_put_object): + mock_minio_put_object.return_value = 'http://mocked-minio-url.com' + + response = self.clients['RTAN'].get( + '/api/credit-agreements/{}/minio_url'.format(self.credit_agreement.id) + ) + # Assert response status + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Assert the mocked URL is returned + self.assertIn('url', response.data) + self.assertEqual(response.data['url'], 'http://mocked-minio-url.com') + \ No newline at end of file diff --git a/backend/api/tests/test_credit_transfers.py b/backend/api/tests/test_credit_transfers.py index 7296d53f1..8375fda5a 100644 --- a/backend/api/tests/test_credit_transfers.py +++ b/backend/api/tests/test_credit_transfers.py @@ -2,6 +2,7 @@ from rest_framework.serializers import ValidationError from django.utils import timezone from django.db import transaction +from unittest import skip from .base_test_case import BaseTestCase from ..models.credit_transfer import CreditTransfer from ..models.credit_transaction import CreditTransaction @@ -11,6 +12,7 @@ from ..models.weight_class import WeightClass from ..models.credit_transaction_type import CreditTransactionType from ..services.credit_transaction import validate_transfer +from ..services.credit_transaction import aggregate_credit_transfer_details from ..models.organization import Organization from ..models.signing_authority_confirmation import SigningAuthorityConfirmation from ..models.signing_authority_assertion import SigningAuthorityAssertion @@ -72,12 +74,10 @@ def create_credit_transfer_content(self, credit_transfer, model_year, credit_cla dollar_value=dollar_value ) def test_list_transfer(self): - def check_response(): - response = self.clients['RTAN_BCEID'].get("/api/credit-transfers") - self.assertEqual(response.status_code, 200) - result = response.data - self.assertEqual(len(result), 2) - transaction.on_commit(check_response) + response = self.clients['RTAN_BCEID'].get("/api/credit-transfers") + self.assertEqual(response.status_code, 200) + result = response.data + self.assertEqual(len(result), 2) def test_list_transfer_as_partner(self): response = self.clients['EMHILLIE_BCEID'].get("/api/credit-transfers") @@ -117,18 +117,26 @@ def test_transfer_fail(self): def test_transfer_pass(self): - """test that: - - a transfer is validated if supplier has enough credits,, - - organization balances are calculated correctly """ - model_year=ModelYear.objects.get(name='2020') - credit_class=CreditClass.objects.get(credit_class="A") - give_org_credits(self.users['EMHILLIE_BCEID'].organization, 100, 4, model_year, credit_class) + """Test that: + - a transfer is validated if supplier has enough credits, + - organization balances are calculated correctly.""" + model_year = ModelYear.objects.get(name='2020') + credit_class = CreditClass.objects.get(credit_class="A") + + give_org_credits( + self.users['EMHILLIE_BCEID'].organization, + 100, + 4, + model_year, + credit_class + ) + transfer_enough = self.create_credit_transfer( self.users['RTAN_BCEID'].organization, self.users['EMHILLIE_BCEID'].organization, 'RECOMMEND_APPROVAL' ) - + self.create_credit_transfer_content( transfer_enough, model_year, @@ -136,23 +144,19 @@ def test_transfer_pass(self): 10, 10 ) - + validate_transfer(transfer_enough) - - def check_balances(): - seller_balance = Organization.objects.filter( - id=self.users['EMHILLIE_BCEID'].organization.id - ).first().balance['A'] - - buyer_balance = Organization.objects.filter( - id=self.users['RTAN_BCEID'].organization.id - ).first().balance['A'] - - self.assertEqual(seller_balance, 390) - self.assertEqual(buyer_balance, 10) - - transaction.on_commit(check_balances) - + + seller_balance = Organization.objects.get( + id=self.users['EMHILLIE_BCEID'].organization.id + ).balance['A'] + + buyer_balance = Organization.objects.get( + id=self.users['RTAN_BCEID'].organization.id + ).balance['A'] + + self.assertEqual(seller_balance, 390) + self.assertEqual(buyer_balance, 10) def test_credit_transfer_create(self): """tets that transfer can be created and email sent""" @@ -299,4 +303,52 @@ def test_org_in_deficit_gets_transfer_credits(self): org_deficits_new_list = list(org_deficits_new.values('credit_class', 'credit_value', 'model_year')) org_deficits_list.sort(key=lambda x: (x['credit_class'], x['model_year'])) org_deficits_new_list.sort(key=lambda x: (x['credit_class'], x['model_year'])) - self.assertEqual(org_deficits_list, org_deficits_new_list) \ No newline at end of file + self.assertEqual(org_deficits_list, org_deficits_new_list) + + def test_aggr_credit_transfer_details(self): + """Test the aggregate_credit_transfer_details function.""" + CreditTransferContent.objects.all().delete() + CreditTransfer.objects.all().delete() + + model_year = ModelYear.objects.get(name='2020') + credit_class = CreditClass.objects.get(credit_class='A') + weight_class = WeightClass.objects.get(weight_class_code='LDV') + + give_org_credits(self.org1, 100, 4, model_year, credit_class) + + transfer1 = self.create_credit_transfer(self.org3, self.org1, 'APPROVED') + self.create_credit_transfer_content(transfer1, model_year, credit_class, 50, 500) + + transfer2 = self.create_credit_transfer(self.org1, self.org3, 'RECOMMEND_APPROVAL') + self.create_credit_transfer_content(transfer2, model_year, credit_class, 30, 300) + + balance = aggregate_credit_transfer_details(self.org1.id) + + self.assertEqual(len(balance), 1) + result = balance[0] + self.assertEqual(result['credit'], 30) # Credits received by org1 + self.assertEqual(result['debit'], 50) # Debits sent by org1 + self.assertEqual(result['credit_value'], -20) # Net balance + + def test_aggr_credit_transfer_invalid_status(self): + """Test that transfers with invalid statuses are ignored.""" + CreditTransferContent.objects.all().delete() + CreditTransfer.objects.all().delete() + + model_year = ModelYear.objects.get(name='2020') + credit_class = CreditClass.objects.get(credit_class='A') + + transfer = self.create_credit_transfer(self.org1, self.org3, 'DRAFT') + self.create_credit_transfer_content(transfer, model_year, credit_class, 100, 1000) + + balance = aggregate_credit_transfer_details(self.org1.id) + + self.assertEqual(len(balance), 0) + + def test_aggr_credit_transfer_no_transfers(self): + """Test aggregate_credit_transfer_details with no transfers.""" + CreditTransferContent.objects.all().delete() + CreditTransfer.objects.all().delete() + + balance = aggregate_credit_transfer_details(self.org1.id) + self.assertEqual(len(balance), 0) diff --git a/backend/api/tests/test_icbc_verification.py b/backend/api/tests/test_icbc_verification.py new file mode 100644 index 000000000..48de40bf7 --- /dev/null +++ b/backend/api/tests/test_icbc_verification.py @@ -0,0 +1,136 @@ +from unittest.mock import patch, MagicMock +from api.models.icbc_upload_date import IcbcUploadDate +from api.tests.base_test_case import BaseTestCase +from api.models.icbc_upload_date import IcbcUploadDate +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework.test import APIClient +from ..models.organization import Organization +import json + + +class IcbcVerificationViewSetTest(BaseTestCase): + def setUp(self): + super().setUp() + organizations = Organization.objects.filter( + is_government=False, + is_active=True + ) + self.user1 = self.users['RTAN_BCEID'] + self.org1 = self.users[self.user1.username].organization + self.date_uri = "/api/icbc-verification/date" + self.chunk_uri = "/api/icbc-verification/chunk_upload" + self.upload_uri = "/api/icbc-verification/upload" + filtered_organizations = [org for org in organizations if org != self.org1] + + @patch("api.models.icbc_upload_date.IcbcUploadDate.objects.last") + def test_get_date(self, mock_last): + """Tests if date endpoint returns last upload date.""" + mock_icbc = IcbcUploadDate(upload_date="2024-12-01", filename="test.csv") + mock_last.return_value = mock_icbc + response = self.clients[self.user1.username].get(self.date_uri) + + self.assertEqual(response.data["upload_date"], "2024-12-01") + self.assertEqual(response.status_code, 200) + + def test_forbidden_chunk_upload(self): + """Tests if chunk_upload endpoint returns 403 if the user is not a government user.""" + with patch.object(type(self.user1), "is_government", False): + response = self.clients[self.user1.username].post(self.chunk_uri) + self.assertEqual(response.status_code, 403) + + # need to mock the rename from chunk upload method otherwise there will be a file error + @patch("os.rename") + def test_chunk_upload_success(self, mock_rename): + """Tests if chunk_upload allows a government user to upload.""" + with patch.object(type(self.user1), "is_government", True): + fake_file = SimpleUploadedFile("test.csv", b"fake file content") + + response = self.clients[self.user1.username].post( + self.chunk_uri, + {"files": fake_file}, + format="multipart", + ) + + self.assertEqual(response.status_code, 201) + mock_rename.assert_called_once() + + @patch("api.viewsets.icbc_verification.minio_remove_object") + @patch("api.viewsets.icbc_verification.ingest_icbc_spreadsheet") + @patch("api.viewsets.icbc_verification.get_minio_object") + @patch("api.models.icbc_upload_date.IcbcUploadDate.objects") + def test_upload_success(self, mock_icbc_objects, mock_get_minio, mock_ingest, mock_remove): + """ + Tests that the upload endpoint processes a valid upload from a government user. + """ + with patch.object(type(self.user1), "is_government", True): + fake_upload_date = MagicMock() + fake_upload_date.filename = "previous_file.xlsx" + fake_upload_date.upload_date = "2024-12-31" + fake_queryset = MagicMock() + fake_queryset.latest.return_value = fake_upload_date + mock_icbc_objects.exclude.return_value = fake_queryset + + fake_previous_file = MagicMock() + fake_previous_file.close = MagicMock() + fake_previous_file.release_conn = MagicMock() + + fake_current_file = MagicMock() + fake_current_file.close = MagicMock() + fake_current_file.release_conn = MagicMock() + + mock_get_minio.side_effect = [fake_previous_file, fake_current_file] + + mock_ingest.return_value = (True, 10, 3) + + data = { + "filename": "current_file.xlsx", + "submission_current_date": "2025-01-31" + } + + response = self.clients[self.user1.username].post( + self.upload_uri, + data, + format="json" + ) + + self.assertEqual(response.status_code, 201) + + response_data = json.loads(response.content) + expected = { + "dateCurrentTo": data["submission_current_date"], + "createdRecords": 10, + "updatedRecords": 3, + } + self.assertEqual(response_data, expected) + + mock_get_minio.assert_any_call("previous_file.xlsx") + mock_get_minio.assert_any_call("current_file.xlsx") + + mock_ingest.assert_called_once_with( + fake_current_file, + "current_file.xlsx", + self.user1, + data["submission_current_date"], + fake_previous_file + ) + + mock_remove.assert_called_once_with("previous_file.xlsx") + + fake_previous_file.close.assert_called_once() + fake_previous_file.release_conn.assert_called_once() + fake_current_file.close.assert_called_once() + fake_current_file.release_conn.assert_called_once() + + def test_upload_forbidden(self): + """Tests that the upload endpoint returns 403 when the user is not a government user.""" + with patch.object(type(self.user1), "is_government", False): + data = { + "filename": "current_file.xlsx", + "submission_current_date": "2025-01-31" + } + response = self.clients[self.user1.username].post( + self.upload_uri, + data, + format="json" + ) + self.assertEqual(response.status_code, 403) diff --git a/backend/api/tests/test_model_year_reports.py b/backend/api/tests/test_model_year_reports.py index 6b27ba7ec..cf38f2f1b 100644 --- a/backend/api/tests/test_model_year_reports.py +++ b/backend/api/tests/test_model_year_reports.py @@ -5,6 +5,7 @@ from .base_test_case import BaseTestCase from ..models.model_year_report import ModelYearReport +from ..models.model_year_report_compliance_obligation import ModelYearReportComplianceObligation from ..models.supplemental_report import SupplementalReport from ..models.model_year_report_statuses import ModelYearReportStatuses from ..models.model_year_report_assessment import ModelYearReportAssessment @@ -12,7 +13,9 @@ from ..models.model_year_report_ldv_sales import ModelYearReportLDVSales from ..models.organization import Organization from ..models.model_year import ModelYear -from unittest.mock import patch +from unittest.mock import patch, MagicMock + +from api.models import model_year CONTENT_TYPE = 'application/json' @@ -33,20 +36,26 @@ def setUp(self): model_year=ModelYear.objects.get(effective_date='2021-01-01'), credit_reduction_selection='A' ) + + self.report = model_year_report + supplementary_report = SupplementalReport.objects.create( model_year_report=model_year_report, create_user='EMHILLIE_BCEID', status=ModelYearReportStatuses.DRAFT, ) + model_year_report_assessment_description = ModelYearReportAssessmentDescriptions.objects.create( description='test', display_order=1 ) + model_year_report_assessment = ModelYearReportAssessment.objects.create( model_year_report=model_year_report, model_year_report_assessment_description=model_year_report_assessment_description, penalty=20.00 ) + reassessment_report = SupplementalReport.objects.create( model_year_report=model_year_report, supplemental_id=supplementary_report.id, @@ -54,13 +63,73 @@ def setUp(self): status=ModelYearReportStatuses.DRAFT, ) + ModelYearReportComplianceObligation.objects.create( + model_year_report=model_year_report, + model_year = ModelYear.objects.get(effective_date='2021-01-01'), + category='ClassAReduction', + credit_a_value=100.00, + credit_b_value=50.00, + reduction_value=10.00, + from_gov=False + ) + + ModelYearReportComplianceObligation.objects.create( + model_year_report=model_year_report, + model_year = ModelYear.objects.get(effective_date='2021-01-01'), + category='CreditDeficit', + credit_a_value=200.00, + credit_b_value=100.00, + reduction_value=20.00, + from_gov=True + ) + + ModelYearReportComplianceObligation.objects.create( + model_year_report=model_year_report, + model_year = ModelYear.objects.get(effective_date='2021-01-01'), + category='UnrelatedCategory', # This should be filtered out + credit_a_value=300.00, + credit_b_value=150.00, + reduction_value=30.00, + from_gov=False + ) + + self.ldv_sales = ModelYearReportLDVSales.objects.create( + model_year=model_year_report.model_year, + model_year_report=model_year_report, + ldv_sales=100, + from_gov=False + ) + def test_status(self): response = self.clients['EMHILLIE_BCEID'].get("/api/compliance/reports") self.assertEqual(response.status_code, 200) result = response.data self.assertEqual(len(result), 1) - + def test_get_ldv_sales_with_year_success(self): + result = self.report.get_ldv_sales_with_year() + self.assertIsNotNone(result) + self.assertEqual(result['sales'], 100) + self.assertEqual(result['year'], '2021') + + def test_get_ldv_sales_with_year_no_match(self): + result = self.report.get_ldv_sales_with_year(from_gov=True) + self.assertIsNone(result) + + def test_get_ldv_sales_with_year_gov_record(self): + ModelYearReportLDVSales.objects.create( + model_year = ModelYear.objects.get(effective_date='2021-01-01'), + model_year_report=self.report, + from_gov=True, + display=True, + ldv_sales=165 + ) + result = self.report.get_ldv_sales_with_year(from_gov=True) + self.assertIsNotNone(result) + self.assertEqual(result['sales'], 165) + self.assertEqual(result['year'], '2021') + + def test_assessment_patch_response(self): with patch('api.services.send_email.send_model_year_report_emails') as mock_send_model_year_report_emails: @@ -132,3 +201,64 @@ def test_assessment_patch_logic(self): # # check that second record is updated, and no new record created # self.assertEqual(sales_records.count(), 2) # self.assertEqual(sales_record.ldv_sales, 50) + + + def test_get_avg_sales_with_organization_sales(self): + self.report.organization.get_avg_ldv_sales = MagicMock(return_value=300) + + result = self.report.get_avg_sales() + self.assertEqual(result, 300) + + def test_get_avg_sales_without_any_sales_data(self): + self.report.organization.get_avg_ldv_sales = MagicMock(return_value=None) + ModelYearReportLDVSales.objects.all().delete() + + result = self.report.get_avg_sales() + self.assertIsNone(result) + + def test_get_avg_sales_with_multiple_report_sales(self): + self.report.organization.get_avg_ldv_sales = MagicMock(return_value=None) + + ModelYearReportLDVSales.objects.create( + model_year_report=self.report, + model_year=ModelYear.objects.get(effective_date='2021-01-01'), + display=True, + ldv_sales=400, + update_timestamp="2023-01-01 10:00:00" + ) + + latest_sales = ModelYearReportLDVSales.objects.create( + model_year_report=self.report, + model_year=ModelYear.objects.get(effective_date='2021-01-01'), + display=True, + ldv_sales=600, + update_timestamp="2023-02-01 10:00:00" + ) + + result = self.report.get_avg_sales() + self.assertEqual(result, latest_sales.ldv_sales) + + def test_get_credit_reductions(self): + results = self.report.get_credit_reductions() + + self.assertEqual(results.count(), 2) + categories = [entry.category for entry in results] + self.assertIn('ClassAReduction', categories) + self.assertIn('CreditDeficit', categories) + self.assertNotIn('SomeOtherCategory', categories) + + def test_get_credit_reductions_without_matching_categories(self): + ModelYearReportComplianceObligation.objects.all().delete() + + ModelYearReportComplianceObligation.objects.create( + model_year_report=self.report, + model_year=ModelYear.objects.get(effective_date='2021-01-01'), + category='NonMatchingCategory', + credit_a_value=200.00, + credit_b_value=100.00 + ) + + reductions = self.report.get_credit_reductions() + + self.assertIsNone(reductions) + diff --git a/backend/api/tests/test_vehicles.py b/backend/api/tests/test_vehicles.py index 986306dd9..35fc66e29 100644 --- a/backend/api/tests/test_vehicles.py +++ b/backend/api/tests/test_vehicles.py @@ -4,38 +4,62 @@ from ..models.vehicle import Vehicle from ..models.user_role import UserRole from ..models.role import Role +from ..models.model_year import ModelYear +from ..models.vehicle_zev_type import ZevType +from ..models.vehicle_class import VehicleClass from unittest.mock import patch +from django.db.models import Q +from django.urls import reverse class TestVehicles(BaseTestCase): - def test_get_vehicles(self): + def setUp(self): + super().setUp() UserRole.objects.create( user_profile_id=self.users['RTAN_BCEID'].id, role=Role.objects.get( role_code='ZEVA User', ) ) - - response = self.clients['RTAN_BCEID'].get("/api/vehicles") - self.assertEqual(response.status_code, 200) - - def test_update_vehicle_state(self): UserRole.objects.create( user_profile_id=self.users['RTAN_BCEID'].id, role=Role.objects.get( role_code='Signing Authority', ) ) - UserRole.objects.create( - user_profile_id=self.users['RTAN_BCEID'].id, - role=Role.objects.get( - role_code='ZEVA User', - ) + self.org1 = self.users['RTAN_BCEID'].organization + my_2023 = ModelYear.objects.get( + name='2023' ) - org1 = self.users['RTAN_BCEID'].organization + self.vehicle = Vehicle.objects.create( + make="Test Manufacturer", + model_name="Test Vehicle", + model_year=my_2023, + weight_kg=1000, + vehicle_zev_type=ZevType.objects.all().first(), + organization=self.org1, + range=200, + vehicle_class_code=VehicleClass.objects.all().first(), + validation_status='DRAFT' + ) + def test_get_vehicles(self): + response = self.clients['RTAN_BCEID'].get("/api/vehicles") + filtered_vehicles = Vehicle.objects.filter( + organization=self.org1 + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), filtered_vehicles.count()) - vehicle = Vehicle.objects.filter(organization=org1).first() + def test_idir_get_vehicles(self): + response = self.clients['RTAN'].get("/api/vehicles") + filtered_vehicles = Vehicle.objects.exclude( + Q(validation_status="DRAFT") | Q(validation_status="DELETED") + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), filtered_vehicles.count()) + def test_update_vehicle_state(self): + vehicle = Vehicle.objects.filter(organization=self.org1).first() if vehicle: # have to reset the status first to draft vehicle.validation_status = 'DRAFT' @@ -50,31 +74,31 @@ def test_update_vehicle_state(self): self.assertEqual(response.status_code, 200) # Test that email method is called properly mock_send_zev_model_emails.assert_called() - + #bceid can get a list of vehicles response = self.clients['RTAN_BCEID'].get("/api/vehicles") self.assertEqual(response.status_code, 200) - + #bceid cannot change the status of a vehicle response = self.clients['RTAN_BCEID'].patch( "/api/vehicles/{}/state_change".format(vehicle.id), content_type='application/json', data=json.dumps({'validation_status': "VALIDATED"}) ) self.assertEqual(response.status_code, 403) - + + #idir user can change the status of a vehicle response = self.clients['RTAN'].patch( "/api/vehicles/{}/state_change".format(vehicle.id), content_type='application/json', data=json.dumps({'validation_status': "VALIDATED"}) ) self.assertEqual(response.status_code, 200) - + #bceid user can get the details of a vehicle response = self.clients['RTAN_BCEID'].get( "/api/vehicles/{}".format(vehicle.id) ) self.assertEqual(response.status_code, 200) def test_create_vehicle(self): - organization = self.users['RTAN_BCEID'].organization response = self.clients['RTAN_BCEID'].post( "/api/vehicles", content_type='application/json', @@ -91,7 +115,6 @@ def test_create_vehicle(self): self.assertEqual(response.status_code, 201) def test_create_vehicle_insufficient_data(self): - organization = self.users['RTAN_BCEID'].organization response = self.clients['RTAN_BCEID'].post( "/api/vehicles", content_type='application/json', @@ -105,7 +128,7 @@ def test_create_vehicle_insufficient_data(self): self.assertEqual(response.status_code, 400) def test_create_vehicle_check_data_match(self): - organization = self.users['RTAN_BCEID'].organization + response = self.clients['RTAN_BCEID'].post( "/api/vehicles", content_type='application/json', @@ -130,3 +153,37 @@ def test_create_vehicle_check_data_match(self): vehicle_zev_type__vehicle_zev_code="PHEV" ) self.assertGreaterEqual(len(car), 1) + + def test_zev_types(self): + response = self.clients['RTAN_BCEID'].get(reverse('vehicle-zev-types')) + self.assertEqual(response.status_code, 200) + self.assertTrue(len(response.data) > 0) + + def test_vehicle_classes(self): + response = self.clients['RTAN_BCEID'].get(reverse('vehicle-classes')) + filtered_classes = VehicleClass.objects.all() + self.assertEqual(response.status_code, 200) + self.assertEquals(filtered_classes.count(), len(response.data)) + + def test_vehicle_years(self): + response = self.clients['RTAN_BCEID'].get(reverse('vehicle-years')) + filtered_years = ModelYear.objects.all().exclude(name__in=['2017', '2018']) + self.assertEqual(response.status_code, 200) + self.assertEquals(filtered_years.count(), len(response.data)) + + def test_minio_url(self): + response = self.clients['RTAN_BCEID'].get( + reverse('vehicle-minio-url', kwargs={'pk': self.vehicle.id}) + ) + self.assertEqual(response.status_code, 200) + self.assertIn('url', response.data) + self.assertIn('minio_object_name', response.data) + + def test_is_active_change(self): + response = self.clients['RTAN_BCEID'].patch( + reverse('vehicle-is-active-change', kwargs={'pk': self.vehicle.id}), + content_type='application/json', + data=json.dumps({'is_active': False}) + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(Vehicle.objects.get(pk=self.vehicle.id).is_active) \ No newline at end of file diff --git a/backend/api/viewsets/credit_agreement.py b/backend/api/viewsets/credit_agreement.py index 531292718..0c475dde6 100644 --- a/backend/api/viewsets/credit_agreement.py +++ b/backend/api/viewsets/credit_agreement.py @@ -150,10 +150,11 @@ def update_comment(self, request, pk): comment_text = request.data.get("comment_text") username = request.user.username comment = get_comment(comment_id) - if username == comment.create_user: - updated_comment = update_comment_text(comment, comment_text) - serializer = CreditAgreementCommentSerializer(updated_comment) - return Response(serializer.data) + if comment: + if username == comment.create_user: + updated_comment = update_comment_text(comment, comment_text) + serializer = CreditAgreementCommentSerializer(updated_comment) + return Response(serializer.data) return Response(status=status.HTTP_403_FORBIDDEN) @action(detail=True, methods=["PATCH"]) diff --git a/backend/api/viewsets/vehicle.py b/backend/api/viewsets/vehicle.py index 7ffc8df28..65376bf5d 100644 --- a/backend/api/viewsets/vehicle.py +++ b/backend/api/viewsets/vehicle.py @@ -74,7 +74,7 @@ def get_queryset(self): @method_decorator(permission_required('VIEW_ZEV')) def list(self, request): """ - Get all the organizations + Get the vehicles """ vehicles = self.get_queryset() diff --git a/developer-guide.md b/developer-guide.md index db4ef8184..8d4732341 100644 --- a/developer-guide.md +++ b/developer-guide.md @@ -138,9 +138,16 @@ Here are a few examples of branch names: to view the database via docker use: docker-compose exec db psql -U postgres zeva +#### Copy down dev data from local + +if you want to have a database full of fake data for testing without logging into openshift, there is a file called zeva-dev.tar in teams under Zeva>Database Files + +move that file into the openshift/scripts folder then rename the file to 'zeva.tar'. Run import-data-from-local.sh in terminal with your local postgres container as the only argument. Ensure that the tar file is deleted after running. Now you will have data locally and just need to assign userIds to the new users that have been imported in. + #### Copy down Test/Prod data from Openshift -Copy test/prod data into your local database using the /openshift/import-data.sh script +Copy test/prod data into your local database using the +/openshift/scripts/import-data.sh script This is a bash script that creates a pg_dump .tar file from and openshift postgres container and imports it into a local postgres container @@ -175,3 +182,10 @@ DROP SCHEMA public cascade; CREATE SCHEMA public AUTHORIZATION postgres; then the script can be run. + +## Assign keycloak ids to users in local database + +if you have grabbed data from dev or test but can't log into any users, you can run the update_users.sql script to assign keycloak logins to users. The file is located in teams under Database Files. Save that to your local and then run the following lines of code to 1. copy it into your postres container on docker 2. go into that container 3. run the script within that container +cp update_users.sql _containerID_:/update_users.sql +docker exec -it _containerID_ bash +psql -U postgres -d zeva -f /update_users.sql diff --git a/frontend/Dockerfile-Openshift b/frontend/Dockerfile-Openshift index 988982a80..6f464ac4d 100644 --- a/frontend/Dockerfile-Openshift +++ b/frontend/Dockerfile-Openshift @@ -1,6 +1,6 @@ # Stage 1: Use yarn to build the app # FROM artifacts.developer.gov.bc.ca/docker-remote/node:16.20.0 as builder -FROM artifacts.developer.gov.bc.ca/docker-remote/node:16.20.0 as builder +FROM artifacts.developer.gov.bc.ca/docker-remote/node:20.18.1 as builder WORKDIR /usr/src/app COPY ./ ./ RUN npm install -g npm@9.1.1 \ diff --git a/frontend/jest.config.js b/frontend/jest.config.js index decf2bf4f..3c58a22c4 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -1,29 +1,30 @@ module.exports = { - testEnvironment: 'jest-environment-jsdom', - collectCoverageFrom: ['src/**/*.{js,jsx}'], - coverageReporters: ['json'], + testEnvironment: "jest-environment-jsdom", + collectCoverageFrom: ["src/**/*.{js,jsx}"], + coverageReporters: ["json", "html", "lcov"], coverageThreshold: { global: { branches: 1, functions: 1, lines: 1, - statements: -6000 - } + statements: -6000, + }, }, - moduleFileExtensions: ['js', 'node', 'json'], + moduleFileExtensions: ["js", "node", "json"], moduleNameMapper: { - '^.+\\.(css|less|scss)$': '/__mocks__/style.js' + "^.+\\.(css|less|scss)$": "/__mocks__/style.js", }, - setupFiles: ['./jest.setup.js'], + setupFiles: ["./jest.setup.js"], testEnvironmentOptions: { - url: 'http://localhost/' + url: "http://localhost/", }, transform: { - '^.+\\.(js|jsx)$': 'babel-jest' + "^.+\\.(js|jsx)$": "babel-jest", }, - coveragePathIgnorePatterns: ['node_modules/'], + testPathIgnorePatterns: ["/node_modules/", "/__tests__/test-data/"], + coveragePathIgnorePatterns: ["node_modules/", "/__tests__/test-data/"], verbose: true, globals: { - __VERSION__: '' - } -} + __VERSION__: "", + }, +}; diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js index 868a32889..484af7e75 100644 --- a/frontend/jest.setup.js +++ b/frontend/jest.setup.js @@ -1,6 +1,6 @@ -import { library } from '@fortawesome/fontawesome-svg-core' -import { fab } from '@fortawesome/free-brands-svg-icons' -import { far } from '@fortawesome/free-regular-svg-icons' -import { fas } from '@fortawesome/free-solid-svg-icons' +import { library } from "@fortawesome/fontawesome-svg-core"; +import { fab } from "@fortawesome/free-brands-svg-icons"; +import { far } from "@fortawesome/free-regular-svg-icons"; +import { fas } from "@fortawesome/free-solid-svg-icons"; -library.add(fab, far, fas) +library.add(fab, far, fas); diff --git a/frontend/package.json b/frontend/package.json index 5ac92bd98..1cf7ccc56 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "zeva-frontend", - "version": "1.64.1", + "version": "1.65.0", "private": true, "dependencies": { "@babel/eslint-parser": "^7.19.1", @@ -64,7 +64,8 @@ "@storybook/addon-storyshots": "^6.1.21", "@storybook/react": "^6.1.21", "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.4.0", + "@testing-library/react": "^10.0.0", + "@testing-library/user-event": "^14.5.2", "babel-eslint": "^7.2.3", "babel-jest": "^24.9.0", "babel-loader": "^8.1.0", diff --git a/frontend/public/config/features.js b/frontend/public/config/features.js index 5c2684cef..067a1b8b6 100644 --- a/frontend/public/config/features.js +++ b/frontend/public/config/features.js @@ -18,5 +18,5 @@ window.zeva_config = { 'purchase_requests.enabled': false, 'roles.enabled': false, 'supplemental.enabled': true, - 'model_year_report.years': [2020, 2021, 2022, 2023] + 'model_year_report.years': [2020, 2021, 2022, 2023, 2024] } diff --git a/frontend/src/app/components/Button.js b/frontend/src/app/components/Button.js index 7db2b8486..7df661583 100644 --- a/frontend/src/app/components/Button.js +++ b/frontend/src/app/components/Button.js @@ -1,8 +1,8 @@ -import React from 'react' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import PropTypes from 'prop-types' -import ReactTooltip from 'react-tooltip' -import history from '../History' +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import PropTypes from "prop-types"; +import ReactTooltip from "react-tooltip"; +import history from "../History"; const Button = (props) => { const { @@ -15,96 +15,96 @@ const Button = (props) => { disabled, optionalClassname, buttonTooltip, - testid - } = props + testid, + } = props; const getRoute = () => { if (locationRoute && locationState) { - return history.push(locationRoute, locationState) + return history.push(locationRoute, locationState); } if (locationRoute) { - return history.push(locationRoute) + return history.push(locationRoute); } - return history.goBack() - } - let text + return history.goBack(); + }; + let text; - let icon - let classname = 'button' - let onclick = () => {} - let tooltip + let icon; + let classname = "button"; + let onclick = () => {}; + let tooltip; if (buttonTooltip && !disabled) { - tooltip = '' + tooltip = ""; } else if (disabled) { - tooltip = buttonTooltip + tooltip = buttonTooltip; } switch (buttonType) { - case 'approve': - text = optionalText - classname += ' primary' - onclick = action - break - case 'back': + case "approve": + text = optionalText; + classname += " primary"; + onclick = action; + break; + case "back": onclick = () => { - getRoute() - } - text = 'Back' - icon = 'arrow-left' - break - case 'delete': - icon = 'trash' - text = 'Delete' - classname += ' text-danger' - onclick = action - break - case 'edit': - icon = 'edit' - text = 'Edit' - onclick = action - break - case 'download': - text = 'Download' - icon = 'download' - onclick = action - break - case 'reject': - text = optionalText - classname += ' text-danger' - onclick = action - break - case 'rescind': - text = 'Rescind Notice' - classname += ' text-danger' - onclick = action - break - case 'save': - text = 'Save' - icon = 'save' + getRoute(); + }; + text = "Back"; + icon = "arrow-left"; + break; + case "delete": + icon = "trash"; + text = "Delete"; + classname += " text-danger"; + onclick = action; + break; + case "edit": + icon = "edit"; + text = "Edit"; + onclick = action; + break; + case "download": + text = "Download"; + icon = "download"; + onclick = action; + break; + case "reject": + text = optionalText; + classname += " text-danger"; + onclick = action; + break; + case "rescind": + text = "Rescind Notice"; + classname += " text-danger"; + onclick = action; + break; + case "save": + text = "Save"; + icon = "save"; if (action) { - onclick = action + onclick = action; } - break - case 'submit': - text = 'Submit' - icon = 'paper-plane' - classname += ' primary' - onclick = action - break + break; + case "submit": + text = "Submit"; + icon = "paper-plane"; + classname += " primary"; + onclick = action; + break; default: - text = optionalText - onclick = action - break + text = optionalText; + onclick = action; + break; } if (optionalText) { - text = optionalText + text = optionalText; } if (optionalIcon) { - icon = optionalIcon + icon = optionalIcon; } if (optionalClassname) { - classname = optionalClassname + classname = optionalClassname; } return ( @@ -116,7 +116,7 @@ const Button = (props) => { className={classname} disabled={disabled} onClick={(e) => { - onclick(e) + onclick(e); }} type="button" > @@ -125,8 +125,8 @@ const Button = (props) => { - ) -} + ); +}; Button.defaultProps = { optionalText: null, @@ -136,15 +136,15 @@ Button.defaultProps = { action: null, optionalClassname: null, disabled: false, - buttonTooltip: '', - testid: '' -} + buttonTooltip: "", + testid: "", +}; Button.propTypes = { buttonType: PropTypes.string.isRequired, locationRoute: PropTypes.string, locationState: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.shape()), - PropTypes.shape() + PropTypes.shape(), ]), optionalText: PropTypes.string, optionalIcon: PropTypes.string, @@ -152,6 +152,6 @@ Button.propTypes = { action: PropTypes.func, disabled: PropTypes.bool, buttonTooltip: PropTypes.string, - testid: PropTypes.string -} -export default Button + testid: PropTypes.string, +}; +export default Button; diff --git a/frontend/src/app/components/FileDrop.js b/frontend/src/app/components/FileDrop.js index 4daaca564..dbbc453c9 100644 --- a/frontend/src/app/components/FileDrop.js +++ b/frontend/src/app/components/FileDrop.js @@ -1,58 +1,63 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import PropTypes from 'prop-types' -import React, { useCallback, useState } from 'react' -import { useDropzone } from 'react-dropzone' +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import PropTypes from "prop-types"; +import React, { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; const FileDrop = ({ setErrorMessage, setFiles, maxFiles, - allowedFileTypes + allowedFileTypes, + currFileLength, }) => { - const [dropMessage, setDropMessage] = useState('') + const [dropMessage, setDropMessage] = useState(""); const onDrop = useCallback((acceptedFiles) => { if (acceptedFiles.length > maxFiles) { setDropMessage( - `Please select only ${maxFiles} file${maxFiles !== 1 ? 's' : ''}.` - ) + `Please select only ${maxFiles} file${maxFiles !== 1 ? "s" : ""}.`, + ); } else { - setDropMessage('') - setErrorMessage('') - setFiles(acceptedFiles) + setDropMessage(""); + setErrorMessage(""); + setFiles(acceptedFiles); } - }, []) + }, []); const { getRootProps, getInputProps } = useDropzone({ onDrop, - accept: allowedFileTypes - }) + accept: allowedFileTypes, + }); return (
-
+

Drag and Drop files here or
- {dropMessage &&
{dropMessage}
}
- ) -} + ); +}; FileDrop.defaultProps = { setErrorMessage: () => {}, - allowedFileTypes: null -} + allowedFileTypes: null, +}; FileDrop.propTypes = { setErrorMessage: PropTypes.func, setFiles: PropTypes.func.isRequired, maxFiles: PropTypes.number.isRequired, - allowedFileTypes: PropTypes.string -} + allowedFileTypes: PropTypes.string, +}; -export default FileDrop +export default FileDrop; diff --git a/frontend/src/app/components/FileDropArea.js b/frontend/src/app/components/FileDropArea.js index d843defbe..9b1672b35 100644 --- a/frontend/src/app/components/FileDropArea.js +++ b/frontend/src/app/components/FileDropArea.js @@ -1,9 +1,10 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React from 'react' -import PropTypes from 'prop-types' -import FileDrop from './FileDrop' -import FileDropEvidence from './FileDropEvidence' -import getFileSize from '../utilities/getFileSize' +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React from "react"; +import PropTypes from "prop-types"; +import FileDrop from "./FileDrop"; +import FileDropEvidence from "./FileDropEvidence"; +import getFileSize from "../utilities/getFileSize"; +import Modal from "./Modal"; const FileDropArea = (props) => { const { @@ -17,42 +18,59 @@ const FileDropArea = (props) => { showProgressBars, submission, type, - wholePageWidth - } = props + wholePageWidth, + confirmDelete = false, + } = props; + const [showModal, setShowModal] = React.useState(false); + const [activeFile, setActiveFile] = React.useState(undefined); const removeFile = (removedFile) => { - const found = files.findIndex((file) => file === removedFile) - files.splice(found, 1) - setErrorMessage('') - setUploadFiles([...files]) - if (type === 'pdf') { - const uploadedIds = submission.evidence.map((each) => each.id) + const found = files.findIndex((file) => file === removedFile); + files.splice(found, 1); + setErrorMessage(""); + setUploadFiles([...files]); + if (type === "pdf") { + const uploadedIds = submission.evidence.map((each) => each.id); if (uploadedIds.includes(removedFile.id)) { - setEvidenceDeleteList([...evidenceDeleteList, removedFile.id]) + setEvidenceDeleteList([...evidenceDeleteList, removedFile.id]); } } - } + }; + // only want to show modal when confirmDelete is true, otherwise delete without. + const handleDelete = (file) => { + if (confirmDelete) { + setShowModal(true); + setActiveFile(file); + } else { + removeFile(file); + } + }; + + const formattedErrorMessage = errorMessage + ? errorMessage.split("|").map((msg, index) =>
{msg}
) + : null; return (
{errorMessage && (
- {errorMessage} + {formattedErrorMessage}
)}
-
- {type === 'excel' && ( +
+ {type === "excel" && ( )} - {type === 'pdf' && ( + {type === "pdf" && ( <> @@ -78,8 +96,8 @@ const FileDropArea = (props) => { )}
{(files.length > 0 || - (type === 'excel' && submission && submission.filename) || - (type === 'pdf' && + (type === "excel" && submission && submission.filename) || + (type === "pdf" && submission && submission.evidence && submission.evidence.length > 0)) && ( @@ -87,18 +105,18 @@ const FileDropArea = (props) => {
Filename
Size
-
+
Delete
- {type === 'excel' && + {type === "excel" && submission.filename && files.length === 0 &&
{submission.filename}
} - {type === 'pdf' && + {type === "pdf" && submission && submission.evidence && submission.evidence .filter( (submissionFile) => - !evidenceDeleteList.includes(submissionFile.id) + !evidenceDeleteList.includes(submissionFile.id), ) .map((submissionFile) => (
{ -
+
, ]}
))} @@ -139,16 +157,12 @@ const FileDropArea = (props) => { {getFileSize(file.size)}
,
- -
+ +
, ]} {showProgressBars && index in progressBars && (
@@ -160,7 +174,7 @@ const FileDropArea = (props) => { className="progress-bar" role="progressbar" style={{ - width: `${progressBars[index]}%` + width: `${progressBars[index]}%`, }} > {progressBars[index]}% @@ -175,20 +189,31 @@ const FileDropArea = (props) => {
+ setShowModal(false)} + handleSubmit={() => { + removeFile(activeFile); + setShowModal(false); + }} + showModal={showModal} + title="Confirm deletion" + > + Are you sure you want to delete your file? +
- ) -} + ); +}; FileDropArea.defaultProps = { - errorMessage: '', + errorMessage: "", evidenceDeleteList: [], files: [], progressBars: {}, showProgressBars: false, submission: {}, setEvidenceDeleteList: () => {}, - wholePageWidth: false -} + wholePageWidth: false, +}; FileDropArea.propTypes = { errorMessage: PropTypes.string, @@ -201,7 +226,7 @@ FileDropArea.propTypes = { showProgressBars: PropTypes.bool, submission: PropTypes.shape(), type: PropTypes.string.isRequired, - wholePageWidth: PropTypes.bool -} + wholePageWidth: PropTypes.bool, +}; -export default FileDropArea +export default FileDropArea; diff --git a/frontend/src/app/components/Tooltip.js b/frontend/src/app/components/Tooltip.js new file mode 100644 index 000000000..957986552 --- /dev/null +++ b/frontend/src/app/components/Tooltip.js @@ -0,0 +1,23 @@ +import React from "react"; +import ReactTooltip from "react-tooltip"; +import PropTypes from "prop-types"; + +const Tooltip = ({ tooltipId, tooltipText, children, placement = "top" }) => ( + + + {children} + +); +Tooltip.propTypes = { + tooltipId: PropTypes.string.isRequired, + tooltipText: PropTypes.string.isRequired, + children: PropTypes.node, + placement: PropTypes.string, +}; + +export default Tooltip; diff --git a/frontend/src/app/components/VehicleSupplierTabs.js b/frontend/src/app/components/VehicleSupplierTabs.js index fc08805a7..52d94f984 100644 --- a/frontend/src/app/components/VehicleSupplierTabs.js +++ b/frontend/src/app/components/VehicleSupplierTabs.js @@ -112,7 +112,7 @@ VehicleSupplierTabs.defaultProps = { VehicleSupplierTabs.propTypes = { active: PropTypes.string.isRequired, locationState: PropTypes.arrayOf(PropTypes.shape()), - supplierId: PropTypes.number, + supplierId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), user: CustomPropTypes.user.isRequired } diff --git a/frontend/src/app/css/App.scss b/frontend/src/app/css/App.scss index 57563c865..b1d1c6311 100644 --- a/frontend/src/app/css/App.scss +++ b/frontend/src/app/css/App.scss @@ -1,29 +1,29 @@ /* BC Sans Regular */ @font-face { - font-family: 'BCSans'; - src: url('./fonts/BCSans-Regular.woff') format('woff'); + font-family: "BCSans"; + src: url("./fonts/BCSans-Regular.woff") format("woff"); } /* BC Sans Bold */ @font-face { - font-family: 'BCSans'; + font-family: "BCSans"; font-weight: bold; - src: url('./fonts/BCSans-Bold.woff') format('woff'); + src: url("./fonts/BCSans-Bold.woff") format("woff"); } /* BC Sans Bold + Italic */ @font-face { - font-family: 'BCSans'; + font-family: "BCSans"; font-style: italic; font-weight: bold; - src: url('./fonts/BCSans-BoldItalic.woff') format('woff'); + src: url("./fonts/BCSans-BoldItalic.woff") format("woff"); } /* BC Sans Italic */ @font-face { - font-family: 'BCSans'; + font-family: "BCSans"; font-style: italic; - src: url('./fonts/BCSans-Italic.woff') format('woff'); + src: url("./fonts/BCSans-Italic.woff") format("woff"); } @mixin shadow { @@ -71,7 +71,7 @@ html, body { color: $default-text-black; - font-family: 'BCSans', sans-serif; + font-family: "BCSans", sans-serif; margin: 0; padding: 0; } @@ -222,6 +222,15 @@ p { text-decoration: underline; } +.link-disabled { + background-color: transparent; + border: none; + margin: 0; + padding: 0; + text-align: left; + text-decoration: underline; +} + .modal-backdrop { opacity: 0.4; } @@ -304,9 +313,11 @@ p { padding: 0; } } + .text-red { color: $red; } + .text-blue { color: $default-text-blue; @@ -315,6 +326,7 @@ p { color: rgba(26, 90, 150, 0.5); } } + .text-black { color: $default-text-black; } @@ -339,11 +351,19 @@ p { } .file-upload { - border: 2px dashed $default-link-blue; - background: $white; + &.disabled { + background-color: $default-background-grey; + color: #afafaf; + border: 2px dashed $border-grey; + } + + &:not(.disabled) { + border: 2px dashed $default-link-blue; + background: $white; + } + padding: 1rem; text-align: center; - flex-direction: column; svg { font-size: 2rem; @@ -373,3 +393,14 @@ button { flex-direction: row; justify-content: center; } + +.info-icon { + color: $default-link-blue; + height: inherit; + margin-right: 6px; +} +.tooltip { + background-color: #2D2D2D !important; + opacity: 100% !important; + max-width: 500px; +} diff --git a/frontend/src/app/css/ComplianceReport.scss b/frontend/src/app/css/ComplianceReport.scss index e5ab009ba..790a4bdfd 100644 --- a/frontend/src/app/css/ComplianceReport.scss +++ b/frontend/src/app/css/ComplianceReport.scss @@ -185,7 +185,7 @@ flex-direction: row; justify-content: flex-end; table { - border-collapse: separate; + border-collapse: separate; border-spacing: 0 0.5em; td { padding: 0 1rem 0 1rem; @@ -254,6 +254,9 @@ } .compliance-reduction-table { border: 1px solid $border-grey; + #obligation-sales-input { + display: flex; + } } table { width: 100%; diff --git a/frontend/src/app/css/ComplianceReportTabs.scss b/frontend/src/app/css/ComplianceReportTabs.scss index d4a457be9..2b6aa5aa8 100644 --- a/frontend/src/app/css/ComplianceReportTabs.scss +++ b/frontend/src/app/css/ComplianceReportTabs.scss @@ -1,5 +1,6 @@ .compliance-report-tabs { .nav-item { + align-self: end; padding: 1rem 1.5rem; &.SAVED { @@ -38,7 +39,7 @@ } } - a { + a,span { border-bottom: 1rem transparent solid; color: $default-text-black; display: block; diff --git a/frontend/src/app/css/FileUpload.scss b/frontend/src/app/css/FileUpload.scss index 6b7c27e70..0c2f47912 100644 --- a/frontend/src/app/css/FileUpload.scss +++ b/frontend/src/app/css/FileUpload.scss @@ -1,3 +1,18 @@ +.ldv-zev-models { + .files { + .header { + color: $default-text-blue; + font-weight: bold; + } + + .delete-icon { + color: $red; + margin-left: 1.25em; + cursor: pointer; + } + } +} + .uploader-files { padding: 0 0.9rem !important; @@ -36,7 +51,7 @@ background-color: $white; } - > div { + >div { padding-bottom: 0.5rem; padding-top: 0.5rem; } diff --git a/frontend/src/app/css/Suppliers.scss b/frontend/src/app/css/Suppliers.scss index b226d99a6..f077f000d 100644 --- a/frontend/src/app/css/Suppliers.scss +++ b/frontend/src/app/css/Suppliers.scss @@ -5,7 +5,7 @@ .supplier-text { color: $default-text-blue; - font-size:large; + font-size: large; } .ldv-sales { @@ -26,7 +26,9 @@ } } - .model-year, .sales, .delete { + .model-year, + .sales, + .delete { display: inline-block; vertical-align: middle; } @@ -34,7 +36,7 @@ background-color: transparent; border: none; color: $red; - } + } } } @@ -48,3 +50,22 @@ h1 { flex-direction: row; justify-content: space-between; } + +.see-more { + &-div { + display: flex; + justify-content: flex-end; + } + &-button { + color: #003366; + border: none; + background: $white; + text-decoration: underline; + margin-right: 10rem; + margin-bottom: 1rem; + } +} + +#supplied-ldv-by-year { + margin-bottom: 1rem; +} diff --git a/frontend/src/app/routes/Compliance.js b/frontend/src/app/routes/Compliance.js index 63e14bb01..fe6934596 100644 --- a/frontend/src/app/routes/Compliance.js +++ b/frontend/src/app/routes/Compliance.js @@ -35,4 +35,4 @@ const COMPLIANCE = { REASSESSMENT_CREDIT_ACTIVITY: `${API_BASE_PATH}/compliance-activity-details/:supp_id/reassessment_credit_activity` } -export default COMPLIANCE +export default COMPLIANCE \ No newline at end of file diff --git a/frontend/src/app/utilities/__tests__/constructReassessmentReduction.test.js b/frontend/src/app/utilities/__tests__/constructReassessmentReduction.test.js new file mode 100644 index 000000000..c4552031d --- /dev/null +++ b/frontend/src/app/utilities/__tests__/constructReassessmentReduction.test.js @@ -0,0 +1,138 @@ +import constructReassessmentReductions from "../constructReassessmentReductions"; +import Big from "big.js"; + +describe("constructReassessmentReductions", () => { + it("should return empty reductions when both inputs are empty", () => { + const result = constructReassessmentReductions([], []); + expect(result).toEqual({ reductionsToUpdate: [], reductionsToAdd: [] }); + }); + + it("should add new reductions when no previous reductions exist", () => { + const reductions = [ + { + type: "Type1", + modelYear: 2023, + creditA: new Big(10), + creditB: new Big(20), + }, + ]; + + const result = constructReassessmentReductions(reductions, []); + + expect(result).toEqual({ + reductionsToUpdate: [], + reductionsToAdd: [ + { creditClass: "A", modelYear: 2023, value: 10 }, + { creditClass: "B", modelYear: 2023, value: 20 }, + ], + }); + }); + + it("should be able to make changes in existing reductions", () => { + const reductions = [ + { + type: "Type1", + modelYear: 2023, + creditA: new Big(15), + creditB: new Big(20), + }, + ]; + const prevReductions = [ + { + type: "Type1", + modelYear: 2023, + creditA: new Big(10), + creditB: new Big(20), + }, + ]; + + const result = constructReassessmentReductions(reductions, prevReductions); + + expect(result).toEqual({ + reductionsToUpdate: [ + { + creditClass: "A", + modelYear: 2023, + oldValue: 10, + newValue: 15, + }, + ], + reductionsToAdd: [], + }); + }); + + it("should add reductions for new types or years not in previous reductions", () => { + const reductions = [ + { + type: "Type1", + modelYear: 2023, + creditA: new Big(10), + creditB: new Big(20), + }, + { + type: "Type2", + modelYear: 2024, + creditA: new Big(5), + creditB: new Big(0), + }, + ]; + const prevReductions = [ + { + type: "Type1", + modelYear: 2023, + creditA: new Big(10), + creditB: new Big(20), + }, + ]; + + const result = constructReassessmentReductions(reductions, prevReductions); + + expect(result).toEqual({ + reductionsToUpdate: [], + reductionsToAdd: [{ creditClass: "A", modelYear: 2024, value: 5 }], + }); + }); + + it("should return empty updates and additions when reductions match previous reductions", () => { + const reductions = [ + { + type: "Type1", + modelYear: 2023, + creditA: new Big(10), + creditB: new Big(20), + }, + ]; + const prevReductions = [ + { + type: "Type1", + modelYear: 2023, + creditA: new Big(10), + creditB: new Big(20), + }, + ]; + + const result = constructReassessmentReductions(reductions, prevReductions); + + expect(result).toEqual({ reductionsToUpdate: [], reductionsToAdd: [] }); + }); + + it("should handle reductions with no corresponding previous reductions", () => { + const reductions = [ + { + type: "Type1", + modelYear: 2023, + creditA: new Big(0), + creditB: new Big(30), + }, + ]; + + const prevReductions = []; + + const result = constructReassessmentReductions(reductions, prevReductions); + + expect(result).toEqual({ + reductionsToUpdate: [], + reductionsToAdd: [{ creditClass: "B", modelYear: 2023, value: 30 }], + }); + }); +}); diff --git a/frontend/src/app/utilities/__tests__/download.test.js b/frontend/src/app/utilities/__tests__/download.test.js new file mode 100644 index 000000000..c88adca4d --- /dev/null +++ b/frontend/src/app/utilities/__tests__/download.test.js @@ -0,0 +1,79 @@ +import download from "../download"; +import axios from "axios"; + +jest.mock("axios"); + +describe("download", () => { + const mockBlob = new Blob(["file content"], { type: "text/plain" }); + const mockSetAttribute = jest.fn(); + const mockClick = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + URL.createObjectURL = jest.fn(); + + document.createElement = jest.fn().mockImplementation((tagName) => { + if (tagName === "a") { + return { + setAttribute: mockSetAttribute, + click: mockClick, + href: "", + }; + } + return {}; + }); + document.body.appendChild = jest.fn(); + }); + + it("should download a file with the filename from content-disposition header", async () => { + const mockResponse = { + data: mockBlob, + headers: { "content-disposition": 'attachment; filename="testfile.txt"' }, + }; + axios.get.mockResolvedValue(mockResponse); + + await download("https://example.com/file", {}, null); + + expect(axios.get).toHaveBeenCalledWith("https://example.com/file", { + responseType: "blob", + }); + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + expect(mockSetAttribute).toHaveBeenCalledWith("download", "testfile.txt"); + expect(mockClick).toHaveBeenCalledTimes(1); + }); + + it("should use the filename override if provided", async () => { + const mockResponse = { + data: mockBlob, + headers: {}, + }; + axios.get.mockResolvedValue(mockResponse); + + await download("https://example.com/file", {}, "customname.txt"); + + expect(axios.get).toHaveBeenCalledWith("https://example.com/file", { + responseType: "blob", + }); + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + expect(mockSetAttribute).toHaveBeenCalledWith("download", "customname.txt"); + expect(mockClick).toHaveBeenCalledTimes(1); + }); + + it("should include additional params in the request", async () => { + const mockResponse = { + data: mockBlob, + headers: { "content-disposition": 'attachment; filename="testfile.txt"' }, + }; + + axios.get.mockResolvedValue(mockResponse); + + const params = { headers: { Authorization: "Bearer token" } }; + await download("https://example.com/file", params); + + expect(axios.get).toHaveBeenCalledWith("https://example.com/file", { + responseType: "blob", + ...params, + }); + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + }); +}); diff --git a/frontend/src/app/utilities/__tests__/formatAddress.test.js b/frontend/src/app/utilities/__tests__/formatAddress.test.js new file mode 100644 index 000000000..583995984 --- /dev/null +++ b/frontend/src/app/utilities/__tests__/formatAddress.test.js @@ -0,0 +1,55 @@ +import formatAddress from "../formatAddress"; + +describe("formatAddress", () => { + it("should return an empty string when no address is provided", () => { + expect(formatAddress(null)).toBe(""); + expect(formatAddress(undefined)).toBe(""); + }); + + it("should return the representative name when only representativeName is provided", () => { + const address = { representativeName: "John Doe" }; + expect(formatAddress(address)).toBe("John Doe"); + }); + + it("should only return address line when it is the only thing provided", () => { + const address = { addressLine1: "123 Main St" }; + expect(formatAddress(address)).toBe("123 Main St"); + }); + + it("should only return address line 2 when it is the only thing provided", () => { + const address = { addressLine2: "1233 Fake St" }; + expect(formatAddress(address)).toBe("1233 Fake St"); + }); + + it("should only return city when it is the only thing provided", () => { + const address = { city: "Vancouver" }; + expect(formatAddress(address)).toBe("Vancouver"); + }); + + it("should return a formatted address with multiple elements", () => { + const address = { + representativeName: "John Doe", + addressLine1: "123 Main St", + addressLine2: "Apt 4B", + city: "Victoria", + state: "BC", + country: "Canada", + postalCode: "V8V 3V3", + }; + const expected = + "John Doe, 123 Main St, Apt 4B, Victoria, BC, Canada, V8V 3V3"; + expect(formatAddress(address)).toBe(expected); + }); + + it("can handle missing elements", () => { + const address = { + representativeName: "John Doe", + addressLine1: "123 Main St", + city: "Victoria", + country: "Canada", + postalCode: "V8V 3V3", + }; + const expected = "John Doe, 123 Main St, Victoria, Canada, V8V 3V3"; + expect(formatAddress(address)).toBe(expected); + }); +}); diff --git a/frontend/src/app/utilities/__tests__/upload.test.js b/frontend/src/app/utilities/__tests__/upload.test.js new file mode 100644 index 000000000..e07b93bde --- /dev/null +++ b/frontend/src/app/utilities/__tests__/upload.test.js @@ -0,0 +1,101 @@ +import { upload, chunkUpload, getFileUploadPromises } from "../upload"; +import axios from "axios"; + +jest.mock("axios"); + +const mockFiles = [new File(["file content"], "testfile.txt")]; +const mockMultipleFiles = [ + new File(["file content"], "testfile.txt"), + new File(["file content"], "testfile2.txt"), +]; +const mockResponse = { + data: { success: true }, +}; +const mockUrl = "https://example.com/upload"; +const additionalData = { key1: "value1", key2: "value2" }; + +describe("upload", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should upload a file", async () => { + axios.post.mockResolvedValue(mockResponse); + + const result = await upload(mockUrl, mockFiles); + + expect(axios.post).toHaveBeenCalledWith(mockUrl, expect.any(FormData), { + headers: { "Content-Type": "multipart/form-data" }, + }); + expect(result).toEqual({ + data: expect.objectContaining({ success: true }), + }); + }); + + it("should upload multiple files", async () => { + axios.post.mockResolvedValue(mockResponse); + await upload(mockUrl, mockMultipleFiles); + expect(axios.post).toHaveBeenCalledWith(mockUrl, expect.any(FormData), { + headers: { "Content-Type": "multipart/form-data" }, + }); + const formData = axios.post.mock.calls[0][1]; + expect(formData.getAll("files").length).toBe(2); + }); + + it("should add additional data to the request", async () => { + axios.post.mockResolvedValue({ data: "success" }); + + const response = await upload(mockUrl, mockFiles, additionalData); + + expect(axios.post).toHaveBeenCalledWith( + mockUrl, + expect.any(FormData), + expect.objectContaining({ + headers: { "Content-Type": "multipart/form-data" }, + }), + ); + + const formData = axios.post.mock.calls[0][1]; + expect(formData.getAll("files").length).toBe(1); + expect(formData.getAll("files")[0]).toEqual(mockFiles[0]); + expect(formData.get("key1")).toBe("value1"); + expect(formData.get("key2")).toBe("value2"); + + expect(response).toEqual({ data: "success" }); + }); +}); + +describe("getFileUploadPromises", () => { + const mockPresignedUrlResponse = { + data: { + url: "http://example.com/upload", + minioObjectName: "mockObjectName", + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(global, "FileReader").mockImplementation(function () { + this.readAsArrayBuffer = jest.fn(() => { + this.onload({ target: { result: "mocked-file-content" } }); + }); + }); + + axios.get.mockResolvedValue(mockPresignedUrlResponse); + axios.put.mockResolvedValue({}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should return an array of promises", () => { + const promises = getFileUploadPromises(mockUrl, mockMultipleFiles); + + expect(promises).toHaveLength(mockMultipleFiles.length); + promises.forEach((promise) => { + expect(promise).toBeInstanceOf(Promise); + }); + }); +}); diff --git a/frontend/src/app/utilities/convertToBig.js b/frontend/src/app/utilities/convertToBig.js index c377243bc..24e220d1e 100644 --- a/frontend/src/app/utilities/convertToBig.js +++ b/frontend/src/app/utilities/convertToBig.js @@ -20,7 +20,7 @@ const convertCarryOverDeficits = (deficits) => { const bigZero = new Big(0) Object.keys(deficits).forEach((year) => { deficits[year].A = deficits[year].A ? new Big(deficits[year].A) : bigZero - deficits[year].unspecified = deficits[year].unspecified ? new Big(deficits[year].B) : bigZero + deficits[year].unspecified = deficits[year].unspecified ? new Big(deficits[year].unspecified) : bigZero }) } diff --git a/frontend/src/app/utilities/urlInsertIdAndYear.js b/frontend/src/app/utilities/urlInsertIdAndYear.js new file mode 100644 index 000000000..2c797c6f7 --- /dev/null +++ b/frontend/src/app/utilities/urlInsertIdAndYear.js @@ -0,0 +1,6 @@ +const urlInsertIdAndYear = (route, id, modelYear) => { + const encode = (str) => encodeURIComponent(str); + return `${route.replace(":id", encode(id))}?year=${encode(modelYear)}`; +} + +export default urlInsertIdAndYear; \ No newline at end of file diff --git a/frontend/src/compliance/AssessmentContainer.js b/frontend/src/compliance/AssessmentContainer.js index cc9bc3e72..6cd946591 100644 --- a/frontend/src/compliance/AssessmentContainer.js +++ b/frontend/src/compliance/AssessmentContainer.js @@ -1,23 +1,24 @@ -import axios from 'axios' -import React, { useEffect, useState } from 'react' -import { useParams } from 'react-router-dom' -import { withRouter } from 'react-router' -import Big from 'big.js' -import Loading from '../app/components/Loading' -import CONFIG from '../app/config' -import history from '../app/History' -import ROUTES_COMPLIANCE from '../app/routes/Compliance' -import CustomPropTypes from '../app/utilities/props' -import AssessmentDetailsPage from './components/AssessmentDetailsPage' -import calculateCreditReductionBig from '../app/utilities/calculateCreditReductionBig' -import getComplianceObligationDetails from '../app/utilities/getComplianceObligationDetails' -import ROUTES_SUPPLEMENTARY from '../app/routes/SupplementaryReport' -import { getNewBalancesStructure, getNewDeficitsStructure } from '../app/utilities/getNewStructures' -import getTotalReductionBig from '../app/utilities/getTotalReductionBig' -import getClassAReductionBig from '../app/utilities/getClassAReductionBig' -import getUnspecifiedClassReductionBig from '../app/utilities/getUnspecifiedClassReductionBig' -import { convertBalances, convertCarryOverDeficits } from '../app/utilities/convertToBig' -import getSnapshottedComplianceRatioReductions from '../app/utilities/getSnapshottedReductions' +import axios from "axios"; +import React, { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { withRouter } from "react-router"; +import Big from "big.js"; +import Loading from "../app/components/Loading"; +import CONFIG from "../app/config"; +import history from "../app/History"; +import ROUTES_COMPLIANCE from "../app/routes/Compliance"; +import urlInsertIdAndYear from "../app/utilities/urlInsertIdAndYear"; +import CustomPropTypes from "../app/utilities/props"; +import AssessmentDetailsPage from "./components/AssessmentDetailsPage"; +import calculateCreditReductionBig from "../app/utilities/calculateCreditReductionBig"; +import getComplianceObligationDetails from "../app/utilities/getComplianceObligationDetails"; +import ROUTES_SUPPLEMENTARY from "../app/routes/SupplementaryReport"; +import { getNewBalancesStructure, getNewDeficitsStructure } from "../app/utilities/getNewStructures"; +import getTotalReductionBig from "../app/utilities/getTotalReductionBig"; +import getClassAReductionBig from "../app/utilities/getClassAReductionBig"; +import getUnspecifiedClassReductionBig from "../app/utilities/getUnspecifiedClassReductionBig"; +import { convertBalances, convertCarryOverDeficits } from "../app/utilities/convertToBig"; +import getSnapshottedComplianceRatioReductions from "../app/utilities/getSnapshottedReductions"; const AssessmentContainer = (props) => { const { keycloak, user } = props @@ -74,7 +75,7 @@ const AssessmentContainer = (props) => { .patch(ROUTES_COMPLIANCE.REPORT_DETAILS.replace(/:id/g, id), data) .then((response) => { history.push(ROUTES_COMPLIANCE.REPORTS) - history.replace(ROUTES_COMPLIANCE.REPORT_SUMMARY.replace(':id', id)) + history.replace(urlInsertIdAndYear(ROUTES_COMPLIANCE.REPORT_SUMMARY, id, reportYear)) }) } diff --git a/frontend/src/compliance/ComplianceObligationContainer.js b/frontend/src/compliance/ComplianceObligationContainer.js index 4066e6970..22853ddef 100644 --- a/frontend/src/compliance/ComplianceObligationContainer.js +++ b/frontend/src/compliance/ComplianceObligationContainer.js @@ -1,23 +1,27 @@ -import axios from 'axios' -import React, { useEffect, useState } from 'react' -import { useParams } from 'react-router-dom' -import CustomPropTypes from '../app/utilities/props' -import ComplianceReportTabs from './components/ComplianceReportTabs' -import ComplianceObligationDetailsPage from './components/ComplianceObligationDetailsPage' -import history from '../app/History' -import ROUTES_SIGNING_AUTHORITY_ASSERTIONS from '../app/routes/SigningAuthorityAssertions' -import ROUTES_COMPLIANCE from '../app/routes/Compliance' -import CONFIG from '../app/config' -import calculateCreditReduction from '../app/utilities/calculateCreditReduction' -import getClassAReduction from '../app/utilities/getClassAReduction' -import getTotalReduction from '../app/utilities/getTotalReduction' -import getUnspecifiedClassReduction from '../app/utilities/getUnspecifiedClassReduction' -import getComplianceObligationDetails from '../app/utilities/getComplianceObligationDetails' -import deleteModelYearReport from '../app/utilities/deleteModelYearReport' -import getNewProvisionalBalance from '../app/utilities/getNewProvisionalBalance' -import { getNewBalancesStructure, getNewDeficitsStructure } from '../app/utilities/getNewStructures' +import axios from "axios"; +import React, { useEffect, useState } from "react"; +import { useParams, useLocation } from "react-router-dom"; +import CustomPropTypes from "../app/utilities/props"; +import ComplianceReportTabs from "./components/ComplianceReportTabs"; +import ComplianceObligationDetailsPage from "./components/ComplianceObligationDetailsPage"; +import history from "../app/History"; +import ROUTES_SIGNING_AUTHORITY_ASSERTIONS from "../app/routes/SigningAuthorityAssertions"; +import ROUTES_COMPLIANCE from "../app/routes/Compliance"; +import urlInsertIdAndYear from "../app/utilities/urlInsertIdAndYear"; +import CONFIG from "../app/config"; +import calculateCreditReduction from "../app/utilities/calculateCreditReduction"; +import getClassAReduction from "../app/utilities/getClassAReduction"; +import getTotalReduction from "../app/utilities/getTotalReduction"; +import getUnspecifiedClassReduction from "../app/utilities/getUnspecifiedClassReduction"; +import getComplianceObligationDetails from "../app/utilities/getComplianceObligationDetails"; +import deleteModelYearReport from "../app/utilities/deleteModelYearReport"; +import getNewProvisionalBalance from "../app/utilities/getNewProvisionalBalance"; +import { getNewBalancesStructure, getNewDeficitsStructure } from "../app/utilities/getNewStructures"; +import qs from "qs"; const ComplianceObligationContainer = (props) => { + const location = useLocation(); + const query = qs.parse(location.search, { ignoreQueryPrefix: true }); const { user } = props const [assertions, setAssertions] = useState([]) @@ -36,7 +40,7 @@ const ComplianceObligationContainer = (props) => { const [ratios, setRatios] = useState({}) const [reportDetails, setReportDetails] = useState({}) const [reportYear, setReportYear] = useState( - CONFIG.FEATURES.MODEL_YEAR_REPORT.DEFAULT_YEAR + isNaN(query.year) ? CONFIG.FEATURES.MODEL_YEAR_REPORT.DEFAULT_YEAR : parseInt(query.year) ) const [sales, setSales] = useState('') const [statuses, setStatuses] = useState({}) @@ -56,12 +60,11 @@ const ComplianceObligationContainer = (props) => { .patch(ROUTES_COMPLIANCE.REPORT_DETAILS.replace(/:id/g, id), data) .then((response) => { history.push(ROUTES_COMPLIANCE.REPORTS) - history.replace( - ROUTES_COMPLIANCE.REPORT_CREDIT_ACTIVITY.replace( - ':id', - response.data.id - ) - ) + history.replace(urlInsertIdAndYear( + ROUTES_COMPLIANCE.REPORT_CREDIT_ACTIVITY, + response.data.id, + reportYear + )) }) } @@ -72,60 +75,59 @@ const ComplianceObligationContainer = (props) => { return false } - setSales(Number(value)) - - if (!isNaN(Number(value))) { - const tempTotalReduction = getTotalReduction( - Number(value), - ratios.complianceRatio - ) - const classAReduction = getClassAReduction( - Number(value), - ratios.zevClassA, - supplierClass - ) - const leftoverReduction = getUnspecifiedClassReduction( - Number(tempTotalReduction), - Number(classAReduction) - ) - - setTotalReduction(tempTotalReduction) + const salesValue = isNaN(Number(value)) ? 0 : Number(value) + setSales(value.length === 0 ? "" : salesValue) + + const tempTotalReduction = getTotalReduction( + salesValue, + ratios.complianceRatio + ) + const classAReduction = getClassAReduction( + salesValue, + ratios.zevClassA, + supplierClass + ) + const leftoverReduction = getUnspecifiedClassReduction( + tempTotalReduction, + classAReduction + ) + + setTotalReduction(tempTotalReduction) + + const tempClassAReductions = [ + ...existingADeductions, + { + modelYear: Number(reportYear), + value: classAReduction + } + ] - const tempClassAReductions = [ - ...existingADeductions, - { - modelYear: Number(reportYear), - value: Number(classAReduction) - } - ] + setClassAReductions(tempClassAReductions) - setClassAReductions(tempClassAReductions) + const tempUnspecifiedReductions = [ + ...existingUnspecifiedDeductions, + { + modelYear: Number(reportYear), + value: leftoverReduction + } + ] - const tempUnspecifiedReductions = [ - ...existingUnspecifiedDeductions, - { - modelYear: Number(reportYear), - value: Number(leftoverReduction) - } - ] + setUnspecifiedReductions(tempUnspecifiedReductions) - setUnspecifiedReductions(tempUnspecifiedReductions) + const creditReduction = calculateCreditReduction( + balances, + tempClassAReductions, + tempUnspecifiedReductions, + creditReductionSelection, + reportDetails.carryOverDeficits + ) - const creditReduction = calculateCreditReduction( - balances, - tempClassAReductions, - tempUnspecifiedReductions, - creditReductionSelection, - reportDetails.carryOverDeficits - ) - - if (supplierClass !== 'S') { - setDeductions(creditReduction.deductions) - setUpdatedBalances({ - balances: creditReduction.balances, - deficits: creditReduction.deficits - }) - } + if (supplierClass !== 'S') { + setDeductions(creditReduction.deductions) + setUpdatedBalances({ + balances: creditReduction.balances, + deficits: creditReduction.deficits + }) } } @@ -294,8 +296,8 @@ const ComplianceObligationContainer = (props) => { } axios.post(ROUTES_COMPLIANCE.OBLIGATION, data).then(() => { history.push(ROUTES_COMPLIANCE.REPORTS) - history.replace( - ROUTES_COMPLIANCE.REPORT_CREDIT_ACTIVITY.replace(':id', id) + history.replace(urlInsertIdAndYear( + ROUTES_COMPLIANCE.REPORT_CREDIT_ACTIVITY, id, reportYear) ) }) } diff --git a/frontend/src/compliance/ComplianceReportSummaryContainer.js b/frontend/src/compliance/ComplianceReportSummaryContainer.js index eb8bb6f48..522f7f009 100644 --- a/frontend/src/compliance/ComplianceReportSummaryContainer.js +++ b/frontend/src/compliance/ComplianceReportSummaryContainer.js @@ -1,17 +1,20 @@ -import axios from 'axios' -import React, { useEffect, useState } from 'react' -import { useParams } from 'react-router-dom' -import CONFIG from '../app/config' -import history from '../app/History' -import Loading from '../app/components/Loading' -import ROUTES_COMPLIANCE from '../app/routes/Compliance' -import CustomPropTypes from '../app/utilities/props' -import ComplianceReportTabs from './components/ComplianceReportTabs' -import ComplianceReportSummaryDetailsPage from './components/ComplianceReportSummaryDetailsPage' -import ROUTES_SIGNING_AUTHORITY_ASSERTIONS from '../app/routes/SigningAuthorityAssertions' -import deleteModelYearReport from '../app/utilities/deleteModelYearReport' +import axios from "axios"; +import React, { useEffect, useState } from "react"; +import { useParams, useLocation } from "react-router-dom"; +import CONFIG from "../app/config"; +import history from "../app/History"; +import ROUTES_COMPLIANCE from "../app/routes/Compliance"; +import urlInsertIdAndYear from "../app/utilities/urlInsertIdAndYear"; +import CustomPropTypes from "../app/utilities/props"; +import ComplianceReportTabs from "./components/ComplianceReportTabs"; +import ComplianceReportSummaryDetailsPage from "./components/ComplianceReportSummaryDetailsPage"; +import ROUTES_SIGNING_AUTHORITY_ASSERTIONS from "../app/routes/SigningAuthorityAssertions"; +import deleteModelYearReport from "../app/utilities/deleteModelYearReport"; +import qs from "qs"; const ComplianceReportSummaryContainer = (props) => { + const location = useLocation(); + const query = qs.parse(location.search, { ignoreQueryPrefix: true }); const { user } = props const [loading, setLoading] = useState(true) const { id } = useParams() @@ -24,7 +27,7 @@ const ComplianceReportSummaryContainer = (props) => { const [creditActivityDetails, setCreditActivityDetails] = useState({}) const [pendingBalanceExist, setPendingBalanceExist] = useState(false) const [modelYear, setModelYear] = useState( - CONFIG.FEATURES.MODEL_YEAR_REPORT.DEFAULT_YEAR + isNaN(query.year) ? CONFIG.FEATURES.MODEL_YEAR_REPORT.DEFAULT_YEAR : parseInt(query.year) ) const [assertions, setAssertions] = useState([]) @@ -49,7 +52,7 @@ const ComplianceReportSummaryContainer = (props) => { axios.patch(ROUTES_COMPLIANCE.REPORT_SUBMISSION.replace(':id', id), data).then(() => { history.push(ROUTES_COMPLIANCE.REPORTS) - history.replace(ROUTES_COMPLIANCE.REPORT_SUMMARY.replace(':id', id)) + history.replace(urlInsertIdAndYear(ROUTES_COMPLIANCE.REPORT_SUMMARY, id, modelYear)) }) } @@ -366,9 +369,6 @@ const ComplianceReportSummaryContainer = (props) => { useEffect(() => { refreshDetails() }, [id]) - if (loading) { - return - } return ( <> diff --git a/frontend/src/compliance/ConsumerSalesContainer.js b/frontend/src/compliance/ConsumerSalesContainer.js index 3f9b33610..103a97cd0 100644 --- a/frontend/src/compliance/ConsumerSalesContainer.js +++ b/frontend/src/compliance/ConsumerSalesContainer.js @@ -1,18 +1,21 @@ -import React, { useEffect, useState } from 'react' -import axios from 'axios' -import { useParams } from 'react-router-dom' - -import CONFIG from '../app/config' -import history from '../app/History' -import CustomPropTypes from '../app/utilities/props' -import ComplianceReportTabs from './components/ComplianceReportTabs' -import ConsumerSalesDetailsPage from './components/ConsumerSalesDetailsPage' -import ROUTES_COMPLIANCE from '../app/routes/Compliance' -import ROUTES_SIGNING_AUTHORITY_ASSERTIONS from '../app/routes/SigningAuthorityAssertions' -import deleteModelYearReport from '../app/utilities/deleteModelYearReport' -import FORECAST_ROUTES from '../salesforecast/constants/routes' +import React, { useEffect, useState } from "react"; +import axios from "axios"; +import { useParams, useLocation } from "react-router-dom"; +import CONFIG from "../app/config"; +import history from "../app/History"; +import CustomPropTypes from "../app/utilities/props"; +import ComplianceReportTabs from "./components/ComplianceReportTabs"; +import ConsumerSalesDetailsPage from "./components/ConsumerSalesDetailsPage"; +import ROUTES_COMPLIANCE from "../app/routes/Compliance"; +import urlInsertIdAndYear from "../app/utilities/urlInsertIdAndYear"; +import ROUTES_SIGNING_AUTHORITY_ASSERTIONS from "../app/routes/SigningAuthorityAssertions"; +import deleteModelYearReport from "../app/utilities/deleteModelYearReport"; +import FORECAST_ROUTES from "../salesforecast/constants/routes"; +import qs from "qs"; const ConsumerSalesContainer = (props) => { + const location = useLocation(); + const query = qs.parse(location.search, { ignoreQueryPrefix: true }); const { keycloak, user } = props const [loading, setLoading] = useState(true) const [vehicles, setVehicles] = useState([]) @@ -21,15 +24,17 @@ const ConsumerSalesContainer = (props) => { const [checked, setChecked] = useState(false) const [checkboxes, setCheckboxes] = useState([]) const [modelYear, setModelYear] = useState( - CONFIG.FEATURES.MODEL_YEAR_REPORT.DEFAULT_YEAR + isNaN(query.year) ? CONFIG.FEATURES.MODEL_YEAR_REPORT.DEFAULT_YEAR : parseInt(query.year) ) const [disabledCheckboxes, setDisabledCheckboxes] = useState(false); const [details, setDetails] = useState({}) const [statuses, setStatuses] = useState({}) const [forecastRecords, setForecastRecords] = useState([]) const [forecastTotals, setForecastTotals] = useState({}) + const [saveTooltip, setSaveTooltip] = useState(""); + const [isSaveDisabled, setIsSaveDisabled] = useState(false); + const [salesForecastDisplay, setSalesForecastDisplay] = useState(false) const { id } = useParams() - const refreshDetails = (showLoading) => { setLoading(showLoading) @@ -74,7 +79,9 @@ const ConsumerSalesContainer = (props) => { setModelYear(year) setStatuses(reportStatuses) - + if (modelYear >= 2023) { + setSalesForecastDisplay(true) + } if (['SAVED', 'UNSAVED'].indexOf( reportStatuses.consumerSales.status ) < 0){ @@ -104,10 +111,7 @@ const ConsumerSalesContainer = (props) => { .then((response) => { history.push(ROUTES_COMPLIANCE.REPORTS) history.replace( - ROUTES_COMPLIANCE.REPORT_CONSUMER_SALES.replace( - ':id', - response.data.id - ) + urlInsertIdAndYear(ROUTES_COMPLIANCE.REPORT_CONSUMER_SALES, id, modelYear) ) }) } @@ -143,7 +147,7 @@ const ConsumerSalesContainer = (props) => { setDisabledCheckboxes(true) history.push(ROUTES_COMPLIANCE.REPORTS) history.replace( - ROUTES_COMPLIANCE.REPORT_CONSUMER_SALES.replace(/:id/g, id) + urlInsertIdAndYear(ROUTES_COMPLIANCE.REPORT_CONSUMER_SALES, id, modelYear) ) }) } @@ -153,6 +157,32 @@ const ConsumerSalesContainer = (props) => { deleteModelYearReport(id, setLoading) } + + useEffect(() => { + const checkDisableSave = () => { + if (checkboxes.length !== assertions.length) { + setSaveTooltip("Please ensure all confirmations checkboxes are checked"); + return true; + } + if ( + (!forecastTotals || + !Object.values(forecastTotals).every( + (value) => + (typeof value === "number" || (!isNaN(value))) && value !== null + ) + ) && salesForecastDisplay) { + setSaveTooltip( + "Please ensure forecast spreadsheet is uploaded and totals are filled out" + ); + return true; + } + setSaveTooltip(""); // Clear tooltip if conditions met + return false; + }; + setIsSaveDisabled(checkDisableSave()); + }, [checkboxes, forecastTotals]); + + useEffect(() => { refreshDetails(true) }, [keycloak.authenticated, modelYear]) @@ -186,6 +216,9 @@ const ConsumerSalesContainer = (props) => { setForecastRecords={setForecastRecords} forecastTotals={forecastTotals} setForecastTotals={setForecastTotals} + saveTooltip={saveTooltip} + isSaveDisabled={isSaveDisabled} + salesForecastDisplay={salesForecastDisplay} /> ) diff --git a/frontend/src/compliance/SupplierInformationContainer.js b/frontend/src/compliance/SupplierInformationContainer.js index 2368eaf49..ffc608a6e 100644 --- a/frontend/src/compliance/SupplierInformationContainer.js +++ b/frontend/src/compliance/SupplierInformationContainer.js @@ -1,29 +1,29 @@ -import axios from 'axios' -import React, { useEffect, useState } from 'react' -import PropTypes from 'prop-types' -import { useParams } from 'react-router-dom' -import { withRouter } from 'react-router' - -import CONFIG from '../app/config' -import history from '../app/History' -import ROUTES_COMPLIANCE from '../app/routes/Compliance' -import ROUTES_VEHICLES from '../app/routes/Vehicles' -import CustomPropTypes from '../app/utilities/props' -import ComplianceReportTabs from './components/ComplianceReportTabs' -import SupplierInformationDetailsPage from './components/SupplierInformationDetailsPage' -import ROUTES_SIGNING_AUTHORITY_ASSERTIONS from '../app/routes/SigningAuthorityAssertions' -import deleteModelYearReport from '../app/utilities/deleteModelYearReport' - -const qs = require('qs') +import axios from "axios"; +import React, { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { useParams } from "react-router-dom"; +import { withRouter } from "react-router"; +import CONFIG from "../app/config"; +import history from "../app/History"; +import ROUTES_COMPLIANCE from "../app/routes/Compliance"; +import urlInsertIdAndYear from "../app/utilities/urlInsertIdAndYear"; +import ROUTES_VEHICLES from "../app/routes/Vehicles"; +import CustomPropTypes from "../app/utilities/props"; +import ComplianceReportTabs from "./components/ComplianceReportTabs"; +import SupplierInformationDetailsPage from "./components/SupplierInformationDetailsPage"; +import ROUTES_SIGNING_AUTHORITY_ASSERTIONS from "../app/routes/SigningAuthorityAssertions"; +import deleteModelYearReport from "../app/utilities/deleteModelYearReport"; +import qs from "qs"; const SupplierInformationContainer = (props) => { const { location, keycloak, user } = props + const query = qs.parse(location.search, { ignoreQueryPrefix: true }); const { id } = useParams() const [assertions, setAssertions] = useState([]) const [checkboxes, setCheckboxes] = useState([]) const [details, setDetails] = useState({}) const [modelYear, setModelYear] = useState( - CONFIG.FEATURES.MODEL_YEAR_REPORT.DEFAULT_YEAR + isNaN(query.year) ? CONFIG.FEATURES.MODEL_YEAR_REPORT.DEFAULT_YEAR : parseInt(query.year) ) const [statuses, setStatuses] = useState({ supplierInformation: { @@ -32,8 +32,6 @@ const SupplierInformationContainer = (props) => { } }) - const query = qs.parse(location.search, { ignoreQueryPrefix: true }) - const [loading, setLoading] = useState(true) const [makes, setMakes] = useState([]) const [make, setMake] = useState('') @@ -72,22 +70,20 @@ const SupplierInformationContainer = (props) => { .patch(ROUTES_COMPLIANCE.REPORT_DETAILS.replace(/:id/g, id), data) .then((response) => { history.push(ROUTES_COMPLIANCE.REPORTS) - history.replace( - ROUTES_COMPLIANCE.REPORT_SUPPLIER_INFORMATION.replace( - ':id', - response.data.id - ) - ) + history.replace(urlInsertIdAndYear( + ROUTES_COMPLIANCE.REPORT_SUPPLIER_INFORMATION, + response.data.id, + modelYear + )) }) } else { axios.post(ROUTES_COMPLIANCE.REPORTS, data).then((response) => { history.push(ROUTES_COMPLIANCE.REPORTS) - history.replace( - ROUTES_COMPLIANCE.REPORT_SUPPLIER_INFORMATION.replace( - ':id', - response.data.id - ) - ) + history.replace(urlInsertIdAndYear( + ROUTES_COMPLIANCE.REPORT_SUPPLIER_INFORMATION, + response.data.id, + modelYear + )) }) } } @@ -137,12 +133,11 @@ const SupplierInformationContainer = (props) => { .patch(ROUTES_COMPLIANCE.REPORT_DETAILS.replace(/:id/g, id), data) .then((response) => { history.push(ROUTES_COMPLIANCE.REPORTS) - history.replace( - ROUTES_COMPLIANCE.REPORT_SUPPLIER_INFORMATION.replace( - ':id', - response.data.id - ) - ) + history.replace(urlInsertIdAndYear( + ROUTES_COMPLIANCE.REPORT_SUPPLIER_INFORMATION, + response.data.id, + modelYear + )) }) } @@ -221,8 +216,9 @@ const SupplierInformationContainer = (props) => { (yearTemp - 2).toString(), (yearTemp - 3).toString() ] + let isSupplied = yearTemp < 2024 ? false : true const previousSales = user.organization.ldvSales.filter( - sales => yearsArray.includes(sales.modelYear.toString()) + sales => yearsArray.includes(sales.modelYear.toString()) && sales.isSupplied === isSupplied ) previousSales.sort((a, b) => (a.modelYear > b.modelYear ? 1 : -1)) const newOrg = { diff --git a/frontend/src/compliance/__tests__/ComplianceCalculatorContainer.test.js b/frontend/src/compliance/__tests__/ComplianceCalculatorContainer.test.js new file mode 100644 index 000000000..88c44138d --- /dev/null +++ b/frontend/src/compliance/__tests__/ComplianceCalculatorContainer.test.js @@ -0,0 +1,226 @@ +import React from "react"; +import { render, act, cleanup, screen, fireEvent } from "@testing-library/react"; +import ComplianceCalculatorContainer from "../ComplianceCalculatorContainer"; +import * as ComplianceCalculatorDetailsPage from "../components/ComplianceCalculatorDetailsPage"; +import * as ReactRouter from "react-router-dom"; +import axios from "axios"; +import ROUTES_COMPLIANCE from "../../app/routes/Compliance"; +import ROUTES_VEHICLES from "../../app/routes/Vehicles"; +import { complianceRatios, getComplianceInfo } from "../components/__testHelpers/CommonTestDataFunctions"; + +const Router = ReactRouter.BrowserRouter; + +const baseUser = { + hasPermission: () => {} +}; + +const baseProps = { + user: baseUser +}; + +const lastModelYear = 2027; +const testModelYears = []; +for (let year = 2019; year <= lastModelYear; year++) { + testModelYears.push({ + name: year.toString(), + effectiveDate: `${year}-01-01`, + expirationDate: `${year}-12-31`, + }); +} + +const vehicleModels = { + model1: { + "id": 1, + "validationStatus": "VALIDATED", + "modelName": "Test Model 1", + "isActive": true, + }, + model2: { + "id": 2, + "validationStatus": "DRAFT", + "modelName": "Test Model 2", + "isActive": true, + }, + model3: { + "id": 3, + "validationStatus": "VALIDATED", + "modelName": "Test Model 3", + "isActive": true, + }, + model4: { + "id": 4, + "validationStatus": "VALIDATED", + "modelName": "Test Model 4", + "isActive": false, + }, +}; +const allVehicleModels = Object.values(vehicleModels); + +const baseExpectedDatailsPageProps = { + complianceNumbers: { total: "", classA: "", remaining: "" }, + complianceYearInfo: {}, + modelYearList: testModelYears, + selectedYearOption: "--", + supplierSize: "", + allVehicleModels: [vehicleModels.model1, vehicleModels.model3], + estimatedModelSales: [{}], + user: baseUser +}; + +const renderComplianceCalculatorContainer = async (props) => { + return await act(async () => { + render( + + + + ); + }); +}; + +class MockedDetailsPage { + constructor() { + this.props = undefined; + jest.spyOn(ComplianceCalculatorDetailsPage, "default").mockImplementation((props) => { + this.props = props; + return ( +
+ +
+ ); + }); + } + + inputValue(id, value) { + const input = screen.getByTestId("input-test-id"); + fireEvent.change(input, { target: { id, value } }); + } + + assertProps(expectedProps) { + const actualProps = {}; + for (const key in expectedProps) { + actualProps[key] = this.props[key]; + } + expect(actualProps).toEqual(expectedProps); + } +} + +// explicitly mock axios here instead of using the mock provided by jest-mock-axios, +// as that mock does not have the axios.spread function +jest.mock("axios", () => { + const originalModule = jest.requireActual("axios"); + return { __esModule: true, ...originalModule }; +}); + +beforeEach(() => { + jest.spyOn(axios, "get").mockImplementation((url) => { + switch (url) { + case ROUTES_VEHICLES.YEARS: + return Promise.resolve({ data: testModelYears }); + case ROUTES_COMPLIANCE.RATIOS: + return Promise.resolve({ data: complianceRatios }); + case ROUTES_VEHICLES.LIST: + return Promise.resolve({ data: allVehicleModels }); + default: + return Promise.resolve({ data: [] }); + } + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + cleanup(); +}); + +describe("Compliance Calculator Container", () => { + test("renders without crashing", async () => { + const _ = new MockedDetailsPage(); + await renderComplianceCalculatorContainer(baseProps); + }); + + + test("renders ComplianceCalculatorDetailsPage with basic initial properties", async () => { + const detailsPage = new MockedDetailsPage(); + await renderComplianceCalculatorContainer(baseProps); + detailsPage.assertProps(baseExpectedDatailsPageProps); + }); + + + test("handle 'model-year' change without sales and supplier-size input", async () => { + const detailsPage = new MockedDetailsPage(); + await renderComplianceCalculatorContainer(baseProps); + + ["2024", "2020", "2021", "2025", "2027"].forEach((selectedYearOption) => { + detailsPage.inputValue("model-year", selectedYearOption); + const complianceYearInfo = getComplianceInfo(selectedYearOption); + detailsPage.assertProps({ ...baseExpectedDatailsPageProps, selectedYearOption, complianceYearInfo }); + }); + }); + + + test("handle 'supplier-size' change without sales and model-year input", async () => { + const detailsPage = new MockedDetailsPage(); + await renderComplianceCalculatorContainer(baseProps); + + ["medium", "large"].forEach((supplierSize) => { + detailsPage.inputValue("supplier-size", supplierSize); + detailsPage.assertProps({ ...baseExpectedDatailsPageProps, supplierSize }); + }); + }); + + + test("handle sales input without model-year and supplier-size", async () => { + const detailsPage = new MockedDetailsPage(); + await renderComplianceCalculatorContainer(baseProps); + + [5000, 7400, 0, 300].forEach((sales) => { + detailsPage.inputValue("total-sales-number", sales.toString()); + detailsPage.assertProps(baseExpectedDatailsPageProps); + }); + }); + + + test("handle model-year, supplier-class and sales inputs", async () => { + const detailsPage = new MockedDetailsPage(); + await renderComplianceCalculatorContainer(baseProps); + + const assertProps = (modelYear, supplierSize, expectedCompliance) => { + detailsPage.assertProps({ + ...baseExpectedDatailsPageProps, + selectedYearOption: modelYear, + supplierSize, + complianceYearInfo: getComplianceInfo(modelYear), + complianceNumbers: expectedCompliance, + }); + }; + + detailsPage.inputValue("supplier-size", "large"); + detailsPage.inputValue("model-year", "2019"); + detailsPage.inputValue("total-sales-number", "5000"); + assertProps("2019", "large", { total: 0, classA: 0, remaining: 0 }); + + detailsPage.inputValue("supplier-size", "medium"); + assertProps("2019", "medium", { total: 0, classA: "NA", remaining: "NA" }); + + detailsPage.inputValue("model-year", "2024"); + assertProps("2024", "medium", { total: 975, classA: "NA", remaining: "NA" }); + + detailsPage.inputValue("supplier-size", "large"); + assertProps("2024", "large", { total: 975, classA: 700, remaining: 275 }); + + detailsPage.inputValue("total-sales-number", "7500"); + assertProps("2024", "large", { total: 1462.5, classA: 1050, remaining: 412.5 }); + + detailsPage.inputValue("model-year", "2026"); + assertProps("2026", "large", { total: 1972.5, classA: 1140, remaining: 832.5 }); + + detailsPage.inputValue("supplier-size", "medium"); + assertProps("2026", "medium", { total: 1972.5, classA: "NA", remaining: "NA" }); + + detailsPage.inputValue("total-sales-number", "3000"); + assertProps("2026", "medium", { total: 789, classA: "NA", remaining: "NA" }); + }); +}); \ No newline at end of file diff --git a/frontend/src/compliance/__tests__/ComplianceObligationContainer.test.js b/frontend/src/compliance/__tests__/ComplianceObligationContainer.test.js new file mode 100644 index 000000000..38d1d4687 --- /dev/null +++ b/frontend/src/compliance/__tests__/ComplianceObligationContainer.test.js @@ -0,0 +1,885 @@ +import React from "react"; +import { render, act, cleanup, screen, fireEvent, } from "@testing-library/react"; +import ComplianceObligationContainer from "../ComplianceObligationContainer"; +import * as ComplianceObligationDetailsPage from "../components/ComplianceObligationDetailsPage"; +import * as ReactRouter from "react-router-dom"; +import axios from "axios"; +import ROUTES_COMPLIANCE from "../../app/routes/Compliance"; +import ROUTES_SIGNING_AUTHORITY_ASSERTIONS from "../../app/routes/SigningAuthorityAssertions"; +import { complianceRatios, getComplianceInfo } from "../components/__testHelpers/CommonTestDataFunctions"; + +const Router = ReactRouter.BrowserRouter; +const salesTestId = "test-input-sales"; +const optionATestId = "test-input-option-A"; +const optionBTestId = "test-input-option-B"; +const saveButtonTestId = "test-button-save"; + +// please do not directly modify any of the consts declared outside of the outermost 'describe' block +// you can, in your individual tests, copy them (the spread operator) and modify the copy + +const baseParams = { + id: 1, +}; + +const baseOrganization = { + name: "Sample Organization", + isGovernment: false, +}; + +const baseUser = { + hasPermission: () => {}, + isGovernment: false, + organization: baseOrganization, +}; + +const baseProps = { + user: baseUser, +}; + +const testCreditTransactions = { + startBalanceAndSalesOnly: { + creditBalanceStart: { + 2019: { A: 120, B: 30 }, + 2020: { A: 25, B: 175 }, + }, + creditsIssuedSales: { + 2020: { A: 50, B: 33 }, + 2021: { A: 300, B: 55 }, + }, + }, + + balances_pending_positiveStart_positiveEnd: { + creditBalanceStart: { + 2020: { A: 250, B: 75 }, + }, + pendingBalance: { + 2021: { A: 141, B: 145 }, + }, + creditsIssuedSales: { + 2019: { A: 145.6, B: 73.5 }, + 2020: { A: 119.8, B: 33.6 }, + 2021: { A: 165.7, B: 55.6 }, + }, + transfersIn: { + 2020: { A: 55, B: 60 }, + 2021: { A: 50, B: 12 }, + }, + transfersOut: { + 2020: { A: 30, B: 10.5 }, + 2021: { A: 40, B: 21 }, + }, + initiativeAgreement: { + 2021: { A: 30, B: 32 }, + }, + purchaseAgreement: { + 2021: { A: 97, B: 89 }, + }, + administrativeAllocation: { + 2021: { A: 110, B: 115 }, + }, + administrativeReduction: { + 2019: { A: 34.5, B: 25.5 }, + 2021: { A: 121, B: 125 }, + }, + automaticAdministrativePenalty: { + 2019: { A: 131.2, B: 105.4 }, + 2020: { A: 141.2, B: 115.4 }, + }, + }, + + balances_pending_positiveStart_negativeEnd: { + creditBalanceStart: { + 2020: { A: 70, B: 15 }, + }, + pendingBalance: { + 2021: { A: 34, B: 12 }, + }, + creditsIssuedSales: { + 2020: { A: 79.8, B: 33.6 }, + 2021: { A: 65.7, B: 45.6 }, + }, + transfersIn: { + 2020: { A: 55, B: 60 }, + 2021: { A: 50, B: 0 }, + }, + transfersOut: { + 2020: { A: 100, B: 80.5 }, + 2021: { A: 40, B: 21 }, + }, + initiativeAgreement: { + 2021: { A: 30, B: 32 }, + }, + purchaseAgreement: { + 2020: { A: 20, B: 20 }, + 2021: { A: 37, B: 24 }, + }, + administrativeAllocation: { + 2019: { A: 35.4, B: 62.8 }, + 2021: { A: 10, B: 15 }, + }, + administrativeReduction: { + 2019: { A: 34.5, B: 25.5 }, + 2021: { A: 121, B: 125 }, + }, + automaticAdministrativePenalty: { + 2021: { A: 15, B: 20 }, + }, + } +} + +const assertionComplianceObligation = { + description: "I confirm this compliance obligation information is complete and correct.", + module: "compliance_obligation", +}; + +jest.mock("react-router-dom", () => { + const originalModule = jest.requireActual("react-router-dom"); + return { + __esModule: true, + ...originalModule, + }; +}); + +// explicitly mock axios here instead of using the mock provided by jest-mock-axios, +// as that mock does not have the axios.spread function, which is used by ComplianceObligationContainer +jest.mock("axios", () => { + const originalModule = jest.requireActual("axios"); + return { __esModule: true, ...originalModule }; +}); + +const getDataByUrl = (url, id, supplierClass, modelYear, complianceObligation) => { + switch (url) { + case ROUTES_COMPLIANCE.REPORT_DETAILS.replace(":id", id): + return { + organization: { + ...baseOrganization, + supplierClass: supplierClass, + }, + organizationName: baseOrganization.name, + supplierClass: supplierClass, + modelYear: { + name: modelYear, + effectiveDate: modelYear + "-01-01", + expirationDate: modelYear + "-12-31", + }, + confirmations: [], + }; + + case ROUTES_COMPLIANCE.RATIOS: + return complianceRatios; + + case ROUTES_COMPLIANCE.REPORT_COMPLIANCE_DETAILS_BY_ID.replace(":id", id): + return { complianceObligation }; + + case ROUTES_SIGNING_AUTHORITY_ASSERTIONS.LIST: + return [ + { + description: "Testing description 1", + module: "other_1", + }, + assertionComplianceObligation, + { + description: "Testing description 2", + module: "other_2", + }, + ]; + + default: + return {}; + } +}; + +const deepRound = (obj) => { + if (Array.isArray(obj)) { + return obj.map(deepRound); + } else if (typeof obj === "object") { + const newObj = {}; + for (const key in obj) { + newObj[key] = deepRound(obj[key]); + } + return newObj; + } else if (typeof obj === "number") { + return Math.round(obj * 100) / 100; + } else { + return obj; + } +}; + +const assertProps = (actualProps, expectedProps) => { + const _actualProps = {}; + for (const key in expectedProps) { + _actualProps[key] = actualProps[key]; + } + expect(deepRound(_actualProps)).toEqual(deepRound(expectedProps)); +}; + +const zeroBalances = (years) => years.map((year) => ({ modelYear: year, creditA: 0, creditB: 0 })); + +const renderContainer = async () => { + await act(async () => { + render( + + + , + ); + }); +}; + +class TestData { + constructor(supplierClass, modelYear, creditTransactions) { + this.supplierClass = supplierClass; + this.modelYear = modelYear; + this.creditTransactions = creditTransactions ?? {}; + this.complianceInfo = getComplianceInfo(modelYear); + this.complianceRatio = this.complianceInfo.complianceRatio; + this.zevClassA = this.complianceInfo.zevClassA; + + // Setup compliance obligation + this.complianceObligation = []; + for (const category in this.creditTransactions) { + const data = this.creditTransactions[category]; + for (const year in data) { + this.complianceObligation.push({ + category: category, + modelYear: { name: year }, + creditAValue: data[year].A, + creditBValue: data[year].B + }); + } + } + + // Mock Axios + jest.spyOn(axios, "get").mockImplementation((url) => { + return Promise.resolve({ + data: getDataByUrl( + url, + baseParams.id, + this.supplierClass, + this.modelYear, + this.complianceObligation, + ), + }); + }); + + this.axiosPostObligation = jest.fn(); + jest.spyOn(axios, "post").mockImplementation((url, data) => { + if (url === ROUTES_COMPLIANCE.OBLIGATION) { + data.creditActivity = "mocked credit activity"; + this.axiosPostObligation(data); + } + return Promise.resolve({}); + }); + + // Mock ComplianceObligationDetailsPage + jest.spyOn(ComplianceObligationDetailsPage, "default").mockImplementation((props) => { + this.detailsPageProps = props; + return ( +
+ + props.handleUnspecifiedCreditReduction("A")} + /> + props.handleUnspecifiedCreditReduction("B")} + /> +
+ ); + }); + + // Setup transactions + this.transactions = { + creditsIssuedSales: this.toTransactionArray(this.creditTransactions.creditsIssuedSales), + transfersIn: this.toTransactionArray(this.creditTransactions.transfersIn), + transfersOut: this.toTransactionArray(this.creditTransactions.transfersOut), + initiativeAgreement: this.toTransactionArray(this.creditTransactions.initiativeAgreement), + purchaseAgreement: this.toTransactionArray(this.creditTransactions.purchaseAgreement), + administrativeAllocation: this.toTransactionArray( + this.creditTransactions.administrativeAllocation + ), + administrativeReduction: this.toTransactionArray( + this.creditTransactions.administrativeReduction + ), + automaticAdministrativePenalty: this.toTransactionArray( + this.creditTransactions.automaticAdministrativePenalty + ), + } + + // Calculate Expected Credit Start Balances + this.expectedCreditBalanceStart = {}; + this.addCredits(this.expectedCreditBalanceStart, this.creditTransactions.creditBalanceStart); + this.addCredits(this.expectedCreditBalanceStart, this.creditTransactions.deficit, -1); + + // Calculate Expected Credit End Balances + this.expectedCreditBalanceEnd = {}; + this.addCredits(this.expectedCreditBalanceEnd, this.expectedCreditBalanceStart); + this.addCredits(this.expectedCreditBalanceEnd, this.creditTransactions.creditsIssuedSales); + this.addCredits(this.expectedCreditBalanceEnd, this.creditTransactions.transfersIn); + this.addCredits(this.expectedCreditBalanceEnd, this.creditTransactions.initiativeAgreement); + this.addCredits(this.expectedCreditBalanceEnd, this.creditTransactions.purchaseAgreement); + this.addCredits(this.expectedCreditBalanceEnd, this.creditTransactions.administrativeAllocation); + this.addCredits(this.expectedCreditBalanceEnd, this.creditTransactions.automaticAdministrativePenalty); + this.addCredits(this.expectedCreditBalanceEnd, this.creditTransactions.transfersOut, -1); + this.addCredits(this.expectedCreditBalanceEnd, this.creditTransactions.administrativeReduction, -1); + + // Calculate Expected Provisional Balance + this.expectedProvisionalBalance = {}; + this.addCredits(this.expectedProvisionalBalance, this.expectedCreditBalanceEnd); + this.addCredits(this.expectedProvisionalBalance, this.creditTransactions.pendingBalance ?? {}); + + // Get Expected Pending Balance + this.expectedPendingBalance = this.toTransactionArray(this.creditTransactions.pendingBalance); + + // Get Expected Report Details + this.expectedReportDetails = { + creditBalanceStart: this.expectedCreditBalanceStart, + creditBalanceEnd: this.expectedCreditBalanceEnd, + deficitCollection: {}, + carryOverDeficits: {}, + pendingBalance: this.expectedPendingBalance, + provisionalBalance: this.expectedProvisionalBalance, + transactions: this.transactions, + }; + } + + toTransactionArray(data) { + if (!data) { + return []; + } + const array = []; + for (const year in data) { + array.push({ + modelYear: year, + A: data[year].A, + B: data[year].B + }); + } + return array; + }; + + addCredits(sum, itemToBeAdded, sign) { + if (!itemToBeAdded) { + return; + } + for (const year in itemToBeAdded) { + if (!sum[year]) { + sum[year] = { A: 0, B: 0 }; + } + sum[year].A += itemToBeAdded[year].A * (sign ?? 1); + sum[year].B += itemToBeAdded[year].B * (sign ?? 1); + } + } + + sumProvisionalBalance(endYear, creditType) { + let sum = 0; + for (const year in this.expectedProvisionalBalance) { + if (year <= endYear) { + sum += this.expectedProvisionalBalance[year][creditType]; + } + } + return sum; + } + + inputSales(sales) { + const salesInput = screen.getByTestId(salesTestId); + fireEvent.change(salesInput, { target: { value: sales } }); + this.expectedTotalReduction = + this.supplierClass !== "S" ? Math.round(sales * this.complianceRatio) / 100 : 0; + this.expectedClassAReduction = + this.supplierClass == "L" ? Math.round(sales * this.zevClassA) / 100 : 0; + this.expectedUnspecifiedReduction = + this.expectedTotalReduction - this.expectedClassAReduction; + + // Return base expected props + return { + assertions: [assertionComplianceObligation], + ratios: this.complianceInfo, + classAReductions: [ + { modelYear: this.modelYear, value: this.expectedClassAReduction }, + ], + unspecifiedReductions: [ + { modelYear: this.modelYear, value: this.expectedUnspecifiedReduction }, + ], + reportYear: this.modelYear, + sales: sales, + supplierClass: this.supplierClass, + totalReduction: this.expectedTotalReduction, + pendingBalanceExist: this.expectedPendingBalance.length > 0, + } + } + + selectCreditOption(optionTestId) { + fireEvent.click(screen.getByTestId(optionTestId)); + } + + assertPropsWithCreditOption(optionTestId, expectedProps, expectedReportDetails) { + this.selectCreditOption(optionTestId); + assertProps(this.detailsPageProps, expectedProps); + assertProps(this.detailsPageProps.reportDetails, expectedReportDetails); + } +} + +beforeEach(() => { + jest.spyOn(ReactRouter, "useParams").mockReturnValue(baseParams); +}); + +afterEach(() => { + jest.restoreAllMocks(); + cleanup(); +}); + +describe("Compliance Obligation Container", () => { + test("renders without crashing", async () => { + const testData = new TestData("L", 2020); + await renderContainer(); + }); + + + for (const supplierClass of ["L", "M", "S"]) { + test(`gets credit reduction with zero or non-numeric sales input for supplier-class ${supplierClass}`, async () => { + const modelYear = 2021; + const testData = new TestData(supplierClass, modelYear); + await renderContainer(); + + const expectedProps = { + assertions: [assertionComplianceObligation], + classAReductions: [{ modelYear: modelYear, value: 0 }], + reportYear: modelYear, + supplierClass, + totalReduction: 0, + }; + + assertProps(testData.detailsPageProps, expectedProps); // assert for initial empty input + + expectedProps.sales = 0; + const salesInput = screen.getByTestId(salesTestId); + + fireEvent.change(salesInput, { target: { value: 0 } }); + assertProps(testData.detailsPageProps, expectedProps); + + fireEvent.change(salesInput, { target: { value: "abc" } }); + assertProps(testData.detailsPageProps, expectedProps); + }); + } + + + for (const testCase of [ + { supplierClass: "L", testSales: 6000 }, + { supplierClass: "M", testSales: 4000 } + ]) { + test(`gets credit reduction with multiple compliance-ratios for supplier-class ${testCase.supplierClass}`, async () => { + const { supplierClass, testSales } = testCase; + for (const complianceInfo of complianceRatios) { + const modelYear = Number(complianceInfo.modelYear); + const testData = new TestData(supplierClass, modelYear); + await renderContainer(); + + const expectedProps = testData.inputSales(testSales); // input a test sales value in the text field + assertProps(testData.detailsPageProps, expectedProps); + document.body.innerHTML = ""; + } + }); + } + + + test("gets credit balance for large supplier, positive start balances, no transactions", async () => { + // Set up test data + const creditTransactions = { + creditBalanceStart: { + 2020: { A: 200, B: 100 }, + 2021: { A: 0, B: 50 }, + 2022: { A: 0, B: 0 }, + 2023: { A: 652.4, B: 418.9 }, + 2024: { A: 702.5, B: 0 }, + } + }; + const testData = new TestData("L", 2025, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(6000); // input a test sales value in the text field + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: [ + ...zeroBalances([2020, 2021, 2022]), + { + modelYear: 2023, + creditA: 0, + creditB: 100 + 50 + 418.9 - testData.expectedUnspecifiedReduction, + }, + { + modelYear: 2024, + creditA: 200 + 652.4 + 702.5 - testData.expectedClassAReduction, + creditB: 0, + }, + ], + deficits: [], + }, + }; + + testData.assertPropsWithCreditOption(optionBTestId, expectedProps, testData.expectedReportDetails); + }); + + + test("gets credit balance for large supplier, deficit start balances, no transactions", async () => { + // Set up test data + const creditTransactions = { + deficit: { + 2021: { A: 18, B: 50.75 }, + 2022: { A: 82, B: 2 }, + 2023: { A: 253.2, B: 420.12 }, + 2024: { A: 102.5, B: 40 }, + 2025: { A: 310, B: 104 }, + } + }; + const modelYear = 2026; + const testData = new TestData("L", modelYear, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(6000); // input a test sales value in the text field + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: zeroBalances([2021, 2022, 2023, 2024, 2025]), + deficits: [ + { + modelYear: modelYear, + creditA: testData.expectedClassAReduction, + creditB: testData.expectedUnspecifiedReduction, + }, + ...testData.toTransactionArray(creditTransactions.deficit).map((x) => ({ + modelYear: x.modelYear, + creditA: x.A, + creditB: x.B, + })), + ], + }, + }; + + const expectedDeficitCollection = {}; + for (const year in creditTransactions.deficit) { + expectedDeficitCollection[year] = { + A: creditTransactions.deficit[year].A, + unspecified: creditTransactions.deficit[year].B, + }; + } + + const expectedReportDetails = { + ...testData.expectedReportDetails, + deficitCollection: expectedDeficitCollection, + carryOverDeficits: expectedDeficitCollection, + pendingBalance: [], + provisionalBalance: { + 2021: { A: 0, B: 0 }, + 2022: { A: 0, B: 0 }, + 2023: { A: 0, B: 0 }, + 2024: { A: 0, B: 0 }, + 2025: { A: 0, B: 0 }, + }, + } + + testData.assertPropsWithCreditOption(optionBTestId, expectedProps, expectedReportDetails); + }); + + + test("gets credit balance for large supplier, sale transactions, option B", async () => { + // Set up test data + const creditTransactions = testCreditTransactions.startBalanceAndSalesOnly; + const testData = new TestData("L", 2021, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(6000); // input a test sales value in the text field + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: [ + ...zeroBalances([2019, 2020]), + { + modelYear: 2021, + creditA: + testData.sumProvisionalBalance(2021, "A") - + testData.expectedClassAReduction, + creditB: + testData.sumProvisionalBalance(2021, "B") - + testData.expectedUnspecifiedReduction, + }, + ], + deficits: [], + }, + }; + + testData.assertPropsWithCreditOption(optionBTestId, expectedProps, testData.expectedReportDetails); + }); + + + test("gets credit balance for large supplier, sale transactions, option A", async () => { + // Set up test data + const creditTransactions = testCreditTransactions.startBalanceAndSalesOnly; + const testData = new TestData("L", 2021, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(6000); // input a test sales value in the text field + + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: [ + ...zeroBalances([2019]), + { + modelYear: 2020, + creditA: 0, + creditB: + testData.sumProvisionalBalance(2021, "A") + + testData.sumProvisionalBalance(2020, "B") - + testData.expectedTotalReduction, + }, + { + modelYear: 2021, + creditA: 0, + creditB: testData.expectedProvisionalBalance["2021"].B, + }, + ], + deficits: [], + }, + }; + + testData.assertPropsWithCreditOption(optionATestId, expectedProps, testData.expectedReportDetails); + }); + + + test("gets credit balance for large supplier, multiple transactions, pending balance, option B, positive start & end balances", async () => { + // Set up test data + const creditTransactions = testCreditTransactions.balances_pending_positiveStart_positiveEnd; + const testData = new TestData("L", 2021, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(6000); // input a test sales value in the text field + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: [ + ...zeroBalances([2019]), + { + modelYear: 2020, + creditA: + testData.sumProvisionalBalance(2020, "A") - + testData.expectedClassAReduction, + creditB: + testData.sumProvisionalBalance(2020, "B") - + testData.expectedUnspecifiedReduction, + }, + { + modelYear: 2021, + creditA: testData.expectedProvisionalBalance["2021"].A, + creditB: testData.expectedProvisionalBalance["2021"].B, + }, + ], + deficits: [], + }, + }; + + testData.assertPropsWithCreditOption(optionBTestId, expectedProps, testData.expectedReportDetails); + }); + + + test("gets credit balance for large supplier, multiple transactions, pending balance, option A, positive start & end balances", async () => { + // Set up test data + const creditTransactions = testCreditTransactions.balances_pending_positiveStart_positiveEnd; + const testData = new TestData("L", 2021, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(6000); // input a test sales value in the text field + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: [ + { + modelYear: 2019, + creditA: 0, + creditB: testData.expectedProvisionalBalance["2019"].B, + }, + { + modelYear: 2020, + creditA: + testData.sumProvisionalBalance(2020, "A") - + testData.expectedTotalReduction, + creditB: testData.expectedProvisionalBalance["2020"].B, + }, + { + modelYear: 2021, + creditA: testData.expectedProvisionalBalance["2021"].A, + creditB: testData.expectedProvisionalBalance["2021"].B, + }, + ], + deficits: [], + }, + }; + testData.assertPropsWithCreditOption(optionATestId, expectedProps, testData.expectedReportDetails); + }); + + + test("gets credit balance for medium supplier, multiple transactions, pending balance, option B, positive start & end balances", async () => { + // Set up test data + const creditTransactions = testCreditTransactions.balances_pending_positiveStart_positiveEnd; + const testData = new TestData("M", 2021, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(4800); // input a test sales value in the text field + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: [ + { + modelYear: 2019, + creditA: testData.expectedProvisionalBalance["2019"].A, + creditB: 0, + }, + { + modelYear: 2020, + creditA: testData.expectedProvisionalBalance["2020"].A, + creditB: 0, + }, + { + modelYear: 2021, + creditA: testData.expectedProvisionalBalance["2021"].A, + creditB: + testData.sumProvisionalBalance(2021, "B") - + testData.expectedTotalReduction, + }, + ], + deficits: [], + }, + }; + + testData.assertPropsWithCreditOption(optionBTestId, expectedProps, testData.expectedReportDetails); + }); + + + test("gets credit balance for medium supplier, multiple transactions, pending balance, option A, positive start & end balances", async () => { + // Set up test data + const creditTransactions = testCreditTransactions.balances_pending_positiveStart_positiveEnd; + const testData = new TestData("M", 2021, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(4800); // input a test sales value in the text field + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: [ + { + modelYear: 2019, + creditA: 0, + creditB: testData.expectedProvisionalBalance["2019"].B, + }, + { + modelYear: 2020, + creditA: + testData.sumProvisionalBalance(2020, "A") - + testData.expectedTotalReduction, + creditB: testData.expectedProvisionalBalance["2020"].B, + }, + { + modelYear: 2021, + creditA: testData.expectedProvisionalBalance["2021"].A, + creditB: testData.expectedProvisionalBalance["2021"].B, + }, + ], + deficits: [], + }, + }; + + testData.assertPropsWithCreditOption(optionATestId, expectedProps, testData.expectedReportDetails); + }); + + + test("gets credit balance for large supplier, multiple transactions, pending balance, options A & B, positive start & negative end balances", async () => { + // Set up test data + const creditTransactions = testCreditTransactions.balances_pending_positiveStart_negativeEnd; + const testData = new TestData("L", 2021, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(6000); // input a test sales value in the text field + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: zeroBalances([2019, 2020, 2021]), + deficits: [ + { + modelYear: 2021, + creditA: + testData.expectedClassAReduction - + testData.sumProvisionalBalance(2021, "A"), + creditB: + testData.expectedUnspecifiedReduction - + testData.sumProvisionalBalance(2021, "B"), + }, + ], + }, + }; + + testData.assertPropsWithCreditOption(optionBTestId, expectedProps, testData.expectedReportDetails); + testData.assertPropsWithCreditOption(optionATestId, expectedProps, testData.expectedReportDetails); + }); + + + test("gets credit balance for medium supplier, multiple transactions, pending balance, option A & B, positive start & negative end balances", async () => { + // Set up test data + const creditTransactions = testCreditTransactions.balances_pending_positiveStart_negativeEnd; + const testData = new TestData("M", 2021, creditTransactions); + await renderContainer(); + + // Set up expected values and assert + const baseExpectedProps = testData.inputSales(4800); // input a test sales value in the text field + const expectedProps = { + ...baseExpectedProps, + updatedBalances: { + balances: zeroBalances([2019, 2020, 2021]), + deficits: [ + { + modelYear: 2021, + creditB: + testData.expectedTotalReduction - + testData.sumProvisionalBalance(2021, "A")- + testData.sumProvisionalBalance(2021, "B"), + }, + ], + }, + }; + + testData.assertPropsWithCreditOption(optionBTestId, expectedProps, testData.expectedReportDetails); + testData.assertPropsWithCreditOption(optionATestId, expectedProps, testData.expectedReportDetails); + }); + + test("handles Save button click", async () => { + // Set up test data + const creditTransactions = testCreditTransactions.balances_pending_positiveStart_positiveEnd; + const testData = new TestData("L", 2021, creditTransactions); + await renderContainer(); + const testSales = 7200; + testData.inputSales(testSales); // input a test sales value in the text field + testData.selectCreditOption(optionBTestId); + + // Set up expected values and assert + const expectedData = { + reportId: baseParams.id, + sales: testSales, + creditActivity: "mocked credit activity", // mocked for now, can be tested in future + confirmations: [], + creditReductionSelection: "B", + }; + + fireEvent.click(screen.getByTestId(saveButtonTestId)); + expect(testData.axiosPostObligation).toHaveBeenCalledWith(expectedData); + }); +}); diff --git a/frontend/src/compliance/__tests__/ComplianceRatiosContainer.test.js b/frontend/src/compliance/__tests__/ComplianceRatiosContainer.test.js new file mode 100644 index 000000000..88b2690d2 --- /dev/null +++ b/frontend/src/compliance/__tests__/ComplianceRatiosContainer.test.js @@ -0,0 +1,77 @@ +import React from "react"; +import { render, act, cleanup, screen } from "@testing-library/react"; +import ComplianceRatiosContainer from "../ComplianceRatiosContainer"; +import * as ComplianceRatiosDetailsPage from "../components/ComplianceRatiosDetailsPage"; +import * as ReactRouter from "react-router-dom"; +import axios from "axios"; +import ROUTES_COMPLIANCE from "../../app/routes/Compliance"; +import { complianceRatios } from "../components/__testHelpers/CommonTestDataFunctions"; + +const Router = ReactRouter.BrowserRouter; + +const baseUser = { + hasPermission: () => {} +}; + +const baseProps = { + user: baseUser +}; + +const renderComplianceRatiosContainer = async (props) => { + return await act(async () => { + render( + + + + ); + }); +}; + +// explicitly mock axios here instead of using the mock provided by jest-mock-axios, +// as that mock does not have the axios.spread function +jest.mock("axios", () => { + const originalModule = jest.requireActual("axios"); + return { __esModule: true, ...originalModule }; +}); + +beforeEach(() => { + jest.spyOn(axios, "get").mockImplementation((url) => { + if (url === ROUTES_COMPLIANCE.RATIOS) { + return Promise.resolve({ data: complianceRatios }); + } + return Promise.resolve({ data: [] }); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + cleanup(); +}); + +describe("Compliance Ratios Container", () => { + test("renders without crashing", async () => { + await renderComplianceRatiosContainer(baseProps); + }); + + + test("renders compliance-ratios-details-page", async () => { + const allProps = []; + jest.spyOn(ComplianceRatiosDetailsPage, "default").mockImplementation((props) => { + allProps.push(props); + return
ComplianceRatiosDetailsPageMock
; + }); + + await renderComplianceRatiosContainer(baseProps); + + const ratiosDetails = screen.queryAllByText("ComplianceRatiosDetailsPageMock"); + + // Expect there is exactly one ComplianceRatiosDetailsPage being shown + expect(ratiosDetails).toHaveLength(1); + + // Expect the data is shown as "loading" first + expect(allProps[0].loading).toBe(true); + + // Expect the correct compliance-ratios are shown eventually + expect(allProps[allProps.length - 1]).toEqual({ user: baseUser, loading: false, complianceRatios }); + }); +}); \ No newline at end of file diff --git a/frontend/src/compliance/components/ComplianceObligationAmountsTable.js b/frontend/src/compliance/components/ComplianceObligationAmountsTable.js index b80243f34..11edf76e6 100644 --- a/frontend/src/compliance/components/ComplianceObligationAmountsTable.js +++ b/frontend/src/compliance/components/ComplianceObligationAmountsTable.js @@ -1,7 +1,8 @@ -import React from 'react' -import PropTypes from 'prop-types' - -import formatNumeric from '../../app/utilities/formatNumeric' +import React from "react"; +import PropTypes from "prop-types"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import formatNumeric from "../../app/utilities/formatNumeric"; +import Tooltip from "../../app/components/Tooltip"; const ComplianceObligationAmountsTable = (props) => { const { @@ -14,22 +15,24 @@ const ComplianceObligationAmountsTable = (props) => { statuses, supplierClass, totalReduction, - unspecifiedReductions - } = props - + unspecifiedReductions, + } = props; + const tooltip = + "Please do not include motor vehicles with a gross vehicle weight rating of more than 3,856 kg that were supplied before October 1, 2024"; const filteredClassAReductions = classAReductions.find( - (reduction) => Number(reduction.modelYear) === Number(reportYear) - ) + (reduction) => Number(reduction.modelYear) === Number(reportYear), + ); const filteredUnspecifiedReductions = unspecifiedReductions.find( - (reduction) => Number(reduction.modelYear) === Number(reportYear) - ) + (reduction) => Number(reduction.modelYear) === Number(reportYear), + ); - let disabledInput = false - if (page === 'obligation'){ - disabledInput = (['SAVED', 'UNSAVED'].indexOf(statuses.complianceObligation.status) < 0) + let disabledInput = false; + if (page === "obligation") { + disabledInput = + ["SAVED", "UNSAVED"].indexOf(statuses.complianceObligation.status) < 0; } - + return (
@@ -39,22 +42,50 @@ const ComplianceObligationAmountsTable = (props) => { - {reportYear} {reportYear < 2024 ? "Model Year LDV Sales" : "Model Year Vehicles Supplied"}: + + + {reportYear}{" "} + {reportYear < 2024 + ? "Model Year LDV Sales" + : "Model Year Vehicles Supplied"} + : + - {page === 'obligation' && - statuses.assessment.status !== 'ASSESSED' && ( - - )} - {(page === 'assessment' || - (page === 'obligation' && - statuses.assessment.status === 'ASSESSED')) && + {page === "obligation" && + statuses.assessment.status !== "ASSESSED" && ( + + + + + + + )} + {(page === "assessment" || + (page === "obligation" && + statuses.assessment.status === "ASSESSED")) && (formatNumeric(sales, 0) || 0)} @@ -65,12 +96,12 @@ const ComplianceObligationAmountsTable = (props) => { Compliance Ratio Credit Reduction: - {supplierClass === 'S' ? formatNumeric(0, 2) : - formatNumeric(totalReduction, 2) - } + {supplierClass === "S" + ? formatNumeric(0, 2) + : formatNumeric(totalReduction, 2)} - {supplierClass === 'L' && ( + {supplierClass === "L" && ( Large Volume Supplier Class A Ratio: @@ -88,9 +119,9 @@ const ComplianceObligationAmountsTable = (props) => { • Unspecified ZEV Class Credit Reduction: - {supplierClass === 'S' ? formatNumeric(0, 2) : - formatNumeric(filteredUnspecifiedReductions.value, 2) - } + {supplierClass === "S" + ? formatNumeric(0, 2) + : formatNumeric(filteredUnspecifiedReductions.value, 2)} @@ -99,12 +130,12 @@ const ComplianceObligationAmountsTable = (props) => {
- ) -} + ); +}; ComplianceObligationAmountsTable.defaultProps = { - handleChangeSales: () => {} -} + handleChangeSales: () => {}, +}; ComplianceObligationAmountsTable.propTypes = { classAReductions: PropTypes.arrayOf(PropTypes.shape()).isRequired, @@ -116,8 +147,9 @@ ComplianceObligationAmountsTable.propTypes = { sales: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, statuses: PropTypes.shape().isRequired, supplierClass: PropTypes.string.isRequired, - totalReduction: PropTypes.oneOfType([PropTypes.number, PropTypes.shape()]).isRequired, - unspecifiedReductions: PropTypes.arrayOf(PropTypes.shape()).isRequired -} + totalReduction: PropTypes.oneOfType([PropTypes.number, PropTypes.shape()]) + .isRequired, + unspecifiedReductions: PropTypes.arrayOf(PropTypes.shape()).isRequired, +}; -export default ComplianceObligationAmountsTable +export default ComplianceObligationAmountsTable; diff --git a/frontend/src/compliance/components/ComplianceObligationDetailsPage.js b/frontend/src/compliance/components/ComplianceObligationDetailsPage.js index dc494ecd5..9ccd25711 100644 --- a/frontend/src/compliance/components/ComplianceObligationDetailsPage.js +++ b/frontend/src/compliance/components/ComplianceObligationDetailsPage.js @@ -1,19 +1,20 @@ /* eslint-disable react/no-array-index-key */ -import React, { useState } from 'react' -import PropTypes from 'prop-types' -import Button from '../../app/components/Button' -import Loading from '../../app/components/Loading' -import CustomPropTypes from '../../app/utilities/props' -import ComplianceReportAlert from './ComplianceReportAlert' -import ComplianceObligationAmountsTable from './ComplianceObligationAmountsTable' -import ComplianceObligationReductionOffsetTable from './ComplianceObligationReductionOffsetTable' -import ComplianceObligationTableCreditsIssued from './ComplianceObligationTableCreditsIssued' -import ComplianceReportSignoff from './ComplianceReportSignOff' -import ComplianceReportDeleteModal from './ComplianceReportDeleteModal' +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import Button from "../../app/components/Button"; +import Loading from "../../app/components/Loading"; +import CustomPropTypes from "../../app/utilities/props"; +import ComplianceReportAlert from "./ComplianceReportAlert"; +import ComplianceObligationAmountsTable from "./ComplianceObligationAmountsTable"; +import ComplianceObligationReductionOffsetTable from "./ComplianceObligationReductionOffsetTable"; +import ComplianceObligationTableCreditsIssued from "./ComplianceObligationTableCreditsIssued"; +import ComplianceReportSignoff from "./ComplianceReportSignOff"; +import ComplianceReportDeleteModal from "./ComplianceReportDeleteModal"; -import Modal from '../../app/components/Modal' -import history from '../../app/History' -import ROUTES_COMPLIANCE from '../../app/routes/Compliance' +import Modal from "../../app/components/Modal"; +import history from "../../app/History"; +import ROUTES_COMPLIANCE from "../../app/routes/Compliance"; +import urlInsertIdAndYear from "../../app/utilities/urlInsertIdAndYear"; const ComplianceObligationDetailsPage = (props) => { const { @@ -224,7 +225,7 @@ const ComplianceObligationDetailsPage = (props) => { optionalText="Next" action={() => { history.push( - ROUTES_COMPLIANCE.REPORT_SUMMARY.replace(':id', id) + urlInsertIdAndYear(ROUTES_COMPLIANCE.REPORT_SUMMARY, id, reportYear) ) }} /> diff --git a/frontend/src/compliance/components/ComplianceReportSignOff.js b/frontend/src/compliance/components/ComplianceReportSignOff.js index 53db835ae..c36a97447 100644 --- a/frontend/src/compliance/components/ComplianceReportSignOff.js +++ b/frontend/src/compliance/components/ComplianceReportSignOff.js @@ -10,15 +10,17 @@ const ComplianceReportSignOff = (props) => { handleCheckboxClick, disabledCheckboxes, hoverText, - user + user, + salesForecastDisplay, } = props - return (
- {assertions.map((assertion) => ( -
+ {assertions + .filter((assertion) => salesForecastDisplay || !/Forecast/i.test(assertion.description)) + .map((assertion) => ( +
{ const { active, reportStatuses, user, modelYear } = props const { id } = useParams() - const disableOtherTabs = + const disableOtherTabs = Object.keys(reportStatuses ?? {}).length === 0 || ( reportStatuses.supplierInformation && reportStatuses.supplierInformation.status === 'UNSAVED' + ); const disableAssessment = (reportStatuses.reportSummary && ['DRAFT'].indexOf(reportStatuses.reportSummary.status) >= 0 && @@ -35,7 +36,7 @@ const ComplianceReportTabs = (props) => { role="presentation" > Supplier Information @@ -51,9 +52,9 @@ const ComplianceReportTabs = (props) => { `} role="presentation" > - {disableOtherTabs && {modelYear < 2024 ? "Consumer Sales" : "ZEVs Supplied and Registered"}} + {disableOtherTabs && {modelYear < 2024 ? "Consumer ZEV Sales" : "ZEVs Supplied and Registered"}} {!disableOtherTabs && ( - + {modelYear < 2024 ? "Consumer ZEV Sales" : "ZEVs Supplied and Registered"} )} @@ -74,7 +75,7 @@ const ComplianceReportTabs = (props) => { )} {!disableOtherTabs && ( Compliance Obligation @@ -94,7 +95,7 @@ const ComplianceReportTabs = (props) => { > {disableOtherTabs && Summary} {!disableOtherTabs && ( - + Summary )} diff --git a/frontend/src/compliance/components/ComplianceReportsTable.js b/frontend/src/compliance/components/ComplianceReportsTable.js index 6bef8fa79..3e7563ae2 100644 --- a/frontend/src/compliance/components/ComplianceReportsTable.js +++ b/frontend/src/compliance/components/ComplianceReportsTable.js @@ -1,15 +1,16 @@ -import PropTypes from 'prop-types' -import React from 'react' +import PropTypes from "prop-types"; +import React from "react"; -import ReactTable from '../../app/components/ReactTable' -import CustomPropTypes from '../../app/utilities/props' -import history from '../../app/History' -import ROUTES_COMPLIANCE from '../../app/routes/Compliance' -import ROUTES_SUPPLEMENTARY from '../../app/routes/SupplementaryReport' -import formatNumeric from '../../app/utilities/formatNumeric' -import getClassAReduction from '../../app/utilities/getClassAReduction' -import getTotalReduction from '../../app/utilities/getTotalReduction' -import formatStatus from '../../app/utilities/formatStatus' +import ReactTable from "../../app/components/ReactTable"; +import CustomPropTypes from "../../app/utilities/props"; +import history from "../../app/History"; +import ROUTES_COMPLIANCE from "../../app/routes/Compliance"; +import urlInsertIdAndYear from "../../app/utilities/urlInsertIdAndYear"; +import ROUTES_SUPPLEMENTARY from "../../app/routes/SupplementaryReport"; +import formatNumeric from "../../app/utilities/formatNumeric"; +import getClassAReduction from "../../app/utilities/getClassAReduction"; +import getTotalReduction from "../../app/utilities/getTotalReduction"; +import formatStatus from "../../app/utilities/formatStatus"; const ComplianceReportsTable = (props) => { const { user, data, showSupplier, filtered, ratios, setFiltered } = props @@ -184,23 +185,24 @@ const ComplianceReportsTable = (props) => { return ( { - if (row && row.original && user) { - return { - onClick: () => { - const { - id, - validationStatus, - supplementalId, - supplementalStatus - } = row.original + getTrProps={(state, row) => { + if (row && row.original && user) { + return { + onClick: () => { + const { + id, + modelYear, + validationStatus, + supplementalId, + supplementalStatus + } = row.original if ( supplementalStatus === 'SUPPLEMENTARY SUBMITTED' && @@ -232,12 +234,11 @@ const ComplianceReportsTable = (props) => { ) } else { // Default show the supplier information page - history.push( - ROUTES_COMPLIANCE.REPORT_SUPPLIER_INFORMATION.replace( - /:id/g, - id - ) - ) + history.push(urlInsertIdAndYear( + ROUTES_COMPLIANCE.REPORT_SUPPLIER_INFORMATION, + id, + modelYear.name + )) } }, className: 'clickable' diff --git a/frontend/src/compliance/components/ConsumerLDVSales.js b/frontend/src/compliance/components/ConsumerLDVSales.js index 732241cb5..715ffa161 100644 --- a/frontend/src/compliance/components/ConsumerLDVSales.js +++ b/frontend/src/compliance/components/ConsumerLDVSales.js @@ -38,7 +38,7 @@ const ConsumerLDVSales = (props) => {
{ handleChangeSale(modelYear, event.target.value) }} diff --git a/frontend/src/compliance/components/ConsumerSalesDetailsPage.js b/frontend/src/compliance/components/ConsumerSalesDetailsPage.js index 30f0d1002..a6c91a850 100644 --- a/frontend/src/compliance/components/ConsumerSalesDetailsPage.js +++ b/frontend/src/compliance/components/ConsumerSalesDetailsPage.js @@ -1,18 +1,19 @@ -import React, { useState } from 'react' -import PropTypes from 'prop-types' -import CustomPropTypes from '../../app/utilities/props' -import Loading from '../../app/components/Loading' -import ComplianceReportAlert from './ComplianceReportAlert' -import Button from '../../app/components/Button' -import Modal from '../../app/components/Modal' -import history from '../../app/History' -import ComplianceReportSignOff from './ComplianceReportSignOff' -import ConsumerSalesLDVModalTable from './ConsumerSalesLDVModelTable' -import ROUTES_COMPLIANCE from '../../app/routes/Compliance' -import ComplianceReportDeleteModal from './ComplianceReportDeleteModal' -import RecordsUpload from '../../salesforecast/components/RecordsUpload' -import RecordsTable from '../../salesforecast/components/RecordsTable' -import TotalsTable from '../../salesforecast/components/TotalsTable' +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import CustomPropTypes from "../../app/utilities/props"; +import Loading from "../../app/components/Loading"; +import ComplianceReportAlert from "./ComplianceReportAlert"; +import Button from "../../app/components/Button"; +import Modal from "../../app/components/Modal"; +import history from "../../app/History"; +import ComplianceReportSignOff from "./ComplianceReportSignOff"; +import ConsumerSalesLDVModalTable from "./ConsumerSalesLDVModelTable"; +import ROUTES_COMPLIANCE from "../../app/routes/Compliance"; +import urlInsertIdAndYear from "../../app/utilities/urlInsertIdAndYear"; +import ComplianceReportDeleteModal from "./ComplianceReportDeleteModal"; +import RecordsUpload from "../../salesforecast/components/RecordsUpload"; +import RecordsTable from "../../salesforecast/components/RecordsTable"; +import TotalsTable from "../../salesforecast/components/TotalsTable"; const ConsumerSalesDetailsPage = (props) => { const { @@ -34,7 +35,10 @@ const ConsumerSalesDetailsPage = (props) => { forecastRecords, setForecastRecords, forecastTotals, - setForecastTotals + setForecastTotals, + saveTooltip, + isSaveDisabled, + salesForecastDisplay } = props const [showModal, setShowModal] = useState(false) @@ -87,12 +91,6 @@ const ConsumerSalesDetailsPage = (props) => { } } - const disableSave = () => { - if (checkboxes.length !== assertions.length) { - return true - } - return false - } return (
@@ -188,7 +186,7 @@ const ConsumerSalesDetailsPage = (props) => {
- {modelYear >= 2023 && + {salesForecastDisplay &&
- )) - : ' - '} + )) + : " - "}
- {details && details.filteredBceidComments && - details.filteredBceidComments.length > 0 && + {details && + details.filteredBceidComments && + details.filteredBceidComments.length > 0 && (

Message from the Director:

@@ -191,7 +199,7 @@ const CreditAgreementsDetailsPage = (props) => { {parse(details.filteredBceidComments[0].comment)}
- } + )}
@@ -201,11 +209,11 @@ const CreditAgreementsDetailsPage = (props) => { - )} + )}
- {directorAction && details && details.status === 'RECOMMENDED' && ( + {directorAction && details && details.status === "RECOMMENDED" && (
{
- {directorAction && details.status === 'RECOMMENDED' && ( + {directorAction && details.status === "RECOMMENDED" && ( <>
{modal} -
- ) -} +
, + ]; +}; CreditAgreementsDetailsPage.propTypes = { details: PropTypes.shape({ @@ -334,7 +351,7 @@ CreditAgreementsDetailsPage.propTypes = { organization: PropTypes.shape(), status: PropTypes.string, transactionType: PropTypes.string, - updateTimestamp: PropTypes.string + updateTimestamp: PropTypes.string, }).isRequired, analystAction: PropTypes.bool.isRequired, directorAction: PropTypes.bool.isRequired, @@ -345,7 +362,7 @@ CreditAgreementsDetailsPage.propTypes = { handleInternalCommentDelete: PropTypes.func.isRequired, handleSubmit: PropTypes.func.isRequired, id: PropTypes.string.isRequired, - user: CustomPropTypes.user.isRequired -} + user: CustomPropTypes.user.isRequired, +}; -export default CreditAgreementsDetailsPage +export default CreditAgreementsDetailsPage; diff --git a/frontend/src/creditagreements/components/CreditAgreementsForm.js b/frontend/src/creditagreements/components/CreditAgreementsForm.js index 5003520c9..6f564e3a6 100644 --- a/frontend/src/creditagreements/components/CreditAgreementsForm.js +++ b/frontend/src/creditagreements/components/CreditAgreementsForm.js @@ -1,15 +1,15 @@ -import axios from 'axios' -import PropTypes from 'prop-types' -import React from 'react' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import axios from "axios"; +import PropTypes from "prop-types"; +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Button from '../../app/components/Button' -import CommentInput from '../../app/components/CommentInput' -import ExcelFileDrop from '../../app/components/FileDrop' -import FormDropdown from '../../credits/components/FormDropdown' -import TextInput from '../../app/components/TextInput' -import getFileSize from '../../app/utilities/getFileSize' -import CustomPropTypes from '../../app/utilities/props' +import Button from "../../app/components/Button"; +import CommentInput from "../../app/components/CommentInput"; +import ExcelFileDrop from "../../app/components/FileDrop"; +import FormDropdown from "../../credits/components/FormDropdown"; +import TextInput from "../../app/components/TextInput"; +import getFileSize from "../../app/utilities/getFileSize"; +import CustomPropTypes from "../../app/utilities/props"; const CreditAgreementsForm = (props) => { const { @@ -31,37 +31,38 @@ const CreditAgreementsForm = (props) => { transactionTypes, user, years, - modelYearReports - } = props + modelYearReports, + } = props; const removeFile = (removedFile) => { - const found = files.findIndex((file) => file === removedFile) - files.splice(found, 1) - setUploadFiles([...files]) - } + const found = files.findIndex((file) => file === removedFile); + files.splice(found, 1); + setUploadFiles([...files]); + }; - const displayModelYear = - !!(agreementDetails.transactionType === 'Reassessment Allocation' || - agreementDetails.transactionType === 'Reassessment Reduction') + const displayModelYear = !!( + agreementDetails.transactionType === "Reassessment Allocation" || + agreementDetails.transactionType === "Reassessment Reduction" + ); const modelYearValues = () => { - const supplierReports = [] + const supplierReports = []; if ( agreementDetails.vehicleSupplier && - (agreementDetails.transactionType === 'Reassessment Allocation' || - agreementDetails.transactionType === 'Reassessment Reduction') + (agreementDetails.transactionType === "Reassessment Allocation" || + agreementDetails.transactionType === "Reassessment Reduction") ) { for (const modelYearReport of modelYearReports) { if ( modelYearReport.organizationId === parseInt(agreementDetails.vehicleSupplier) ) { - supplierReports.push(modelYearReport) + supplierReports.push(modelYearReport); } } } - return supplierReports - } + return supplierReports; + }; return (
@@ -95,7 +96,7 @@ const CreditAgreementsForm = (props) => { {agreementDetails.attachments && agreementDetails.attachments .filter( - (attachment) => deleteFiles.indexOf(attachment.id) < 0 + (attachment) => deleteFiles.indexOf(attachment.id) < 0, ) .map((attachment) => (
@@ -105,25 +106,25 @@ const CreditAgreementsForm = (props) => { onClick={() => { axios .get(attachment.url, { - responseType: 'blob', + responseType: "blob", headers: { - Authorization: null - } + Authorization: null, + }, }) .then((response) => { const objectURL = window.URL.createObjectURL( - new Blob([response.data]) - ) - const link = document.createElement('a') - link.href = objectURL + new Blob([response.data]), + ); + const link = document.createElement("a"); + link.href = objectURL; link.setAttribute( - 'download', - attachment.filename - ) - document.body.appendChild(link) - link.click() - }) + "download", + attachment.filename, + ); + document.body.appendChild(link); + link.click(); + }); }} type="button" > @@ -137,7 +138,7 @@ const CreditAgreementsForm = (props) => {
- ) -} + ); +}; CreditAgreementsForm.defaultProps = { - id: null -} + id: null, +}; CreditAgreementsForm.propTypes = { addRow: PropTypes.func.isRequired, @@ -428,7 +434,7 @@ CreditAgreementsForm.propTypes = { analystAction: PropTypes.bool.isRequired, creditRows: PropTypes.arrayOf(PropTypes.shape()).isRequired, deleteFiles: PropTypes.arrayOf( - PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + PropTypes.oneOfType([PropTypes.string, PropTypes.number]), ).isRequired, files: PropTypes.arrayOf(PropTypes.shape()).isRequired, handleChangeDetails: PropTypes.func.isRequired, @@ -442,7 +448,7 @@ CreditAgreementsForm.propTypes = { suppliers: PropTypes.arrayOf(PropTypes.shape()).isRequired, transactionTypes: PropTypes.arrayOf(PropTypes.shape()).isRequired, user: CustomPropTypes.user.isRequired, - years: PropTypes.arrayOf(PropTypes.shape()).isRequired -} + years: PropTypes.arrayOf(PropTypes.shape()).isRequired, +}; -export default CreditAgreementsForm +export default CreditAgreementsForm; diff --git a/frontend/src/creditagreements/components/__tests__/CreditAgreementsAlert.test.js b/frontend/src/creditagreements/components/__tests__/CreditAgreementsAlert.test.js new file mode 100644 index 000000000..357df2b46 --- /dev/null +++ b/frontend/src/creditagreements/components/__tests__/CreditAgreementsAlert.test.js @@ -0,0 +1,98 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import CreditAgreementsAlert from "../CreditAgreementsAlert"; +import Alert from "../../../app/components/Alert"; + +jest.mock("../../../app/components/Alert", () => + jest.fn(() =>
), +); + +describe("CreditAgreementsAlert", () => { + const mockProps = { + status: "ISSUED", + date: "2025-01-01", + isGovernment: false, + id: "123", + transactionType: "Purchase Agreement", + updateUser: { displayName: "John Doe" }, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + const scenarios = [ + { + description: "renders with correct props for DRAFT status", + props: { ...mockProps, status: "DRAFT" }, + expected: { + title: "Draft", + icon: "exclamation-circle", + classname: "alert-warning", + message: "saved, 2025-01-01 by John Doe.", + }, + }, + { + description: "renders with correct props for RECOMMENDED status", + props: { ...mockProps, status: "RECOMMENDED" }, + expected: { + title: "Recommended", + icon: "exclamation-circle", + classname: "alert-primary", + message: "recommended for issuance, 2025-01-01 by John Doe.", + }, + }, + { + description: "renders with correct props for RETURNED status", + props: { ...mockProps, status: "RETURNED" }, + expected: { + title: "Returned", + icon: "exclamation-circle", + classname: "alert-primary", + message: "PA-123 returned 2025-01-01 by the Director", + }, + }, + { + description: + "renders with correct props for ISSUED status (non-government)", + props: mockProps, + expected: { + title: "Issued", + icon: "check-circle", + classname: "alert-success", + message: "PA-123 issued 2025-01-01 by the Government of B.C.", + }, + }, + { + description: "renders with correct props for ISSUED status (government)", + props: { ...mockProps, isGovernment: true }, + expected: { + title: "Issued", + icon: "check-circle", + classname: "alert-success", + message: "PA-123 issued 2025-01-01 by the Director", + }, + }, + { + description: "renders with default transaction type when not matched", + props: { ...mockProps, transactionType: "Unknown Type" }, + expected: { + message: "IA-123 issued 2025-01-01 by the Government of B.C.", + }, + }, + { + description: "renders with empty title for unknown status", + props: { ...mockProps, status: "UNKNOWN" }, + expected: { + title: "", + }, + }, + ]; + + scenarios.forEach(({ description, props, expected }) => { + it(description, () => { + render(); + expect(Alert).toHaveBeenCalledWith(expect.objectContaining(expected), {}); + }); + }); +}); diff --git a/frontend/src/creditagreements/components/__tests__/CreditAgreementsForm.test.js b/frontend/src/creditagreements/components/__tests__/CreditAgreementsForm.test.js new file mode 100644 index 000000000..f4316aee5 --- /dev/null +++ b/frontend/src/creditagreements/components/__tests__/CreditAgreementsForm.test.js @@ -0,0 +1,237 @@ +import React from "react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import CreditAgreementsForm from "../CreditAgreementsForm"; +import "@testing-library/jest-dom/extend-expect"; + +describe("CreditAgreementsForm", () => { + const mockProps = { + addRow: jest.fn(), + agreementDetails: { + vehicleSupplier: null, + transactionType: null, + effectiveDate: "", + attachments: [], + bceidComment: "", + }, + analystAction: true, + creditRows: [], + deleteFiles: [], + files: [], + handleChangeDetails: jest.fn(), + handleChangeRow: jest.fn(), + handleCommentChangeBceid: jest.fn(), + handleDeleteRow: jest.fn(), + handleSubmit: jest.fn(), + id: null, + setDeleteFiles: jest.fn(), + setUploadFiles: jest.fn(), + suppliers: [{ id: 1, name: "Supplier 1" }], + transactionTypes: [{ name: "Transaction 1" }], + user: { isGovernment: true }, + years: [{ name: "2025" }], + modelYearReports: [], + }; + + // currently does not work because of dynamic class names that change on each run + xit("matches the snapshot", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("calls handleChangeDetails when a dropdown changes", () => { + render(); + + const supplierDropdown = screen.getByLabelText("Vehicle Supplier"); + fireEvent.change(supplierDropdown, { target: { value: "1" } }); + + expect(mockProps.handleChangeDetails).toHaveBeenCalledWith( + "1", + "vehicleSupplier", + true, + ); + }); + + it("calls addRow when the add line button is clicked", () => { + render(); + + const addLineButton = screen.getByText(/Add another line/i); + fireEvent.click(addLineButton); + + expect(mockProps.addRow).toHaveBeenCalled(); + }); + + it("calls handleSubmit when the Save button is clicked", () => { + const updatedProps = { + ...mockProps, + agreementDetails: { + ...mockProps.agreementDetails, + vehicleSupplier: "Supplier 1", + transactionType: "Transaction 1", + effectiveDate: "2025-01-01", + }, + }; + + render(); + + const saveButton = screen.getByText("Save"); + fireEvent.click(saveButton); + + expect(updatedProps.handleSubmit).toHaveBeenCalledWith(""); + }); + + it("renders files correctly in the attachments section", () => { + const testFiles = [ + { id: 1, filename: "TestFile1.pdf", size: 1024, url: "test-url-1" }, + ]; + + render( + , + ); + + expect(screen.getByText("TestFile1.pdf")).toBeInTheDocument(); + }); + + it("removes a file when the delete button is clicked", () => { + const testFiles = [ + { id: 1, filename: "TestFile1.pdf", size: 1024, url: "test-url-1" }, + ]; + + const { container } = render( + , + ); + + const deleteButton = container.querySelector(".delete"); + fireEvent.click(deleteButton); + + expect(mockProps.setDeleteFiles).toHaveBeenCalledWith([1]); + }); + + it("renders transaction date input correctly", () => { + render(); + + const transactionDateInput = screen.getByLabelText("Transaction Date"); + expect(transactionDateInput).toBeInTheDocument(); + fireEvent.change(transactionDateInput, { target: { value: "2025-01-01" } }); + + expect(mockProps.handleChangeDetails).toHaveBeenCalledWith( + "2025-01-01", + "effectiveDate", + ); + }); + + it("renders attachment section when files are present", () => { + const updatedProps = { + ...mockProps, + agreementDetails: { + ...mockProps.agreementDetails, + attachments: [ + { id: 1, filename: "TestFile1.pdf", size: 1024, url: "test-url-1" }, + ], + }, + }; + + render(); + + expect(screen.getByText("TestFile1.pdf")).toBeInTheDocument(); + }); + + it("navigates to the correct route when Back button is clicked", () => { + render(); + + const backButton = screen.getByText(/Back/i); + fireEvent.click(backButton); + + expect(window.location.pathname).toBe("/credit-agreements/"); + }); + + it("renders Agreement ID input when displayModelYear is false", () => { + render(); + + const agreementIdInput = screen.getByLabelText("Agreement ID (optional)"); + expect(agreementIdInput).toBeInTheDocument(); + }); + + it("calls handleChangeRow when the model year dropdown changes", () => { + const mockHandleChangeRow = jest.fn(); + const updatedProps = { + ...mockProps, + handleChangeRow: mockHandleChangeRow, + creditRows: [{ modelYear: "2025", quantity: "10" }], + years: [{ name: "2025" }, { name: "2026" }], + }; + + render(); + + const modelYearDropdown = screen.getByLabelText("model year"); + fireEvent.change(modelYearDropdown, { target: { value: "2026" } }); + + expect(mockHandleChangeRow).toHaveBeenCalledWith("2026", "modelYear", 0); + }); + + it("calls handleChangeRow when the quantity input changes", () => { + const mockHandleChangeRow = jest.fn(); + const updatedProps = { + ...mockProps, + handleChangeRow: mockHandleChangeRow, + creditRows: [{ modelYear: "2025", quantity: "10" }], + }; + + render(); + + const quantityInput = screen.getByLabelText("quantity of credits"); + fireEvent.change(quantityInput, { target: { value: "15" } }); + + expect(mockHandleChangeRow).toHaveBeenCalledWith("15", "quantity", 0); + }); + + it("calls handleChangeRow when the credit class radio button changes", () => { + const mockHandleChangeRow = jest.fn(); + const updatedProps = { + ...mockProps, + handleChangeRow: mockHandleChangeRow, + creditRows: [{ creditClass: "A", modelYear: "2025", quantity: "10" }], + }; + + render(); + const creditClassB = screen.getByLabelText("B credits"); + fireEvent.click(creditClassB); + + expect(mockHandleChangeRow).toHaveBeenCalledWith("B", "creditClass", 0); + }); + + it("returns model year values matching vehicle supplier and transaction type", () => { + const updatedProps = { + ...mockProps, + agreementDetails: { + ...mockProps.agreementDetails, + vehicleSupplier: "1", + transactionType: "Reassessment Allocation", + }, + modelYearReports: [ + { id: 1, organizationId: 1, name: "2025" }, + { id: 2, organizationId: 2, name: "2026" }, + ], + }; + + render(); + + const modelYearDropdown = screen.getByLabelText("Model Year"); + const options = within(modelYearDropdown).getAllByRole("option"); + + // will return 2 options because of placeholder + expect(options).toHaveLength(2); + expect(options[1]).toHaveTextContent("2025"); + }); +}); diff --git a/frontend/src/creditagreements/components/__tests__/CreditAgreementsListPage.test.js b/frontend/src/creditagreements/components/__tests__/CreditAgreementsListPage.test.js new file mode 100644 index 000000000..ad7b8a70c --- /dev/null +++ b/frontend/src/creditagreements/components/__tests__/CreditAgreementsListPage.test.js @@ -0,0 +1,42 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; +import CreditAgreementsListPage from "../CreditAgreementsListPage"; + +describe("CreditAgreementsListPage", () => { + const mockProps = { + creditAgreements: [], + filtered: [], + handleClear: jest.fn(), + loading: false, + setFiltered: jest.fn(), + user: { + isGovernment: true, + roles: [{ roleCode: "Director" }], + }, + }; + + it("renders the Loading component when loading is true", () => { + render(); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("renders the header and filter section when user.isGovernment is true", () => { + render(); + + expect( + screen.getByText("Credit Agreements & Adjustments"), + ).toBeInTheDocument(); + expect(screen.getByText(/filter by/i)).toBeInTheDocument(); + }); + + it("calls handleClear when the clear button is clicked in CreditAgreementsFilter", () => { + render(); + + const clearButton = screen.getByText(/clear/i); + clearButton.click(); + + expect(mockProps.handleClear).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/credits/__tests__/CreditRequestDetailsContainer.test.js b/frontend/src/credits/__tests__/CreditRequestDetailsContainer.test.js new file mode 100644 index 000000000..bad9120fd --- /dev/null +++ b/frontend/src/credits/__tests__/CreditRequestDetailsContainer.test.js @@ -0,0 +1,231 @@ +import React from "react"; +import { render, act, cleanup } from "@testing-library/react"; +import CreditRequestDetailsContainer from "../CreditRequestDetailsContainer"; +import * as CreditRequestDetailsPage from "../components/CreditRequestDetailsPage"; +import { MemoryRouter as Router, Route } from "react-router-dom"; +import axios from "axios"; +import ROUTES_ICBCVERIFICATION from "../../app/routes/ICBCVerification"; +import ROUTES_CREDIT_REQUESTS from "../../app/routes/CreditRequests"; +import { baseUser, testComments, MockedComponent } from "./test-data/commonTestData"; + +const testIds = { + checkbox: "testId-checkbox", + showWarning: "testId-show-warning", + submit: "testId-submit", + commentEdit: "testId-comment-edit", + commentDelete: "testId-comment-delete", +}; +const creditRequestId = "12"; +const defaultIssueAsMY = true; + +const baseProps = { + user: baseUser +}; + +const dateResponseData = { + "uploadDate": "2024-09-01", + "updateTimestamp": "2024-11-14T15:04:12" +}; + +const submissionResponseData = { + id: 1, + content: [], + salesSubmissionComment: testComments +}; + +const baseExpectedDatailsPageProps = { + submission: submissionResponseData, + uploadDate: dateResponseData, + user: baseProps.user, + issueAsMY: defaultIssueAsMY, +}; + +const renderCreditRequestDetailsContainer = (props) => { + return act(async () => { + render( + + + + + + ); + }); +}; + +class MockedDetailsPage extends MockedComponent { + constructor() { + super("Mocked Credit Request Details Page"); + jest.spyOn(CreditRequestDetailsPage, "default").mockImplementation((props) => { + this.props = props; + return ( +
+
{this.mockedComponentText}
+ props.handleCheckboxClick({ target: { checked: this.inputParams.checked } })} + /> + props.setShowWarning(this.inputParams.showWarning)} + /> + props.handleSubmit(this.inputParams.validationStatus, this.inputParams.comment)} + /> + props.handleInternalCommentEdit(this.inputParams.id, this.inputParams.comment)} + /> + props.handleInternalCommentDelete(this.inputParams.id)} + /> +
+ ); + }); + } + + setShowWarning(value) { + this.props.setShowWarning(value); + }; +} + +// explicitly mock axios here instead of using the mock provided by jest-mock-axios, +// as that mock does not have the axios.spread function +jest.mock("axios", () => { + const originalModule = jest.requireActual("axios"); + return { __esModule: true, ...originalModule }; +}); + +const originalWindowLocation = window.location; +const windowLocationReload = jest.fn(); + +beforeAll(() => { + delete window.location; + window.location = { ...originalWindowLocation, reload: windowLocationReload }; +}); + +afterAll(() => { + window.location = originalWindowLocation; +}); + +beforeEach(() => { + jest.spyOn(axios, "get").mockImplementation((url) => { + switch (url) { + case ROUTES_ICBCVERIFICATION.DATE: + return Promise.resolve({ data: dateResponseData }); + case ROUTES_CREDIT_REQUESTS.DETAILS.replace(":id", creditRequestId): + return Promise.resolve({ data: submissionResponseData }); + default: + throw new Error(`Unexpected URL: ${url}`); + } + }); +}); + +afterEach(() => { + windowLocationReload.mockClear(); + jest.restoreAllMocks(); + cleanup(); +}); + +describe("Credit Request Details Container", () => { + test("renders CreditRequestDetailsPage with basic initial properties", async () => { + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestDetailsContainer(baseProps); + detailsPage.assertRendered(); + detailsPage.assertProps(baseExpectedDatailsPageProps); + }); + + + test("triggers checkbox click", async () => { + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestDetailsContainer(baseProps); + + await detailsPage.triggerInput(testIds.checkbox, { checked: false }); + detailsPage.assertProps({...baseExpectedDatailsPageProps, issueAsMY: false}); + + await detailsPage.triggerInput(testIds.checkbox, { checked: true }); + detailsPage.assertProps({...baseExpectedDatailsPageProps, issueAsMY: true}); + }); + + + [ + { validationStatus: "SUBMITTED", reloadCount: 1 }, + { validationStatus: "SUBMITTED", comment: "Test Comment", reloadCount: 1 }, + { validationStatus: "VALIDATED", reloadCount: 1 }, + { validationStatus: "VALIDATED", comment: "Test Comment", reloadCount: 1 }, + { validationStatus: "RECOMMEND_APPROVAL", showWarning: true, issueAsMY: defaultIssueAsMY }, + { validationStatus: "RECOMMEND_APPROVAL", comment: "Test Comment", showWarning: true, issueAsMY: defaultIssueAsMY }, + { validationStatus: "OTHER" }, + { validationStatus: "OTHER", comment: "Test Comment" }, + ].forEach((testParams) => { + const { validationStatus, comment, reloadCount, showWarning, issueAsMY } = testParams; + test(`triggers submit in ${validationStatus} status with${comment ? "" : " no"} comment`, async () => { + const axiosPatchCreditRequestDetails = jest.fn(); + jest.spyOn(axios, "patch").mockImplementation((url, data) => { + expect(url).toBe(ROUTES_CREDIT_REQUESTS.DETAILS.replace(":id", creditRequestId)); + axiosPatchCreditRequestDetails(data); + return Promise.resolve({}); + }); + + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestDetailsContainer(baseProps); + + if (showWarning) { + await detailsPage.triggerInput(testIds.showWarning, { showWarning }); + } + await detailsPage.triggerInput(testIds.submit, { validationStatus, comment }); + + expect(windowLocationReload).toHaveBeenCalledTimes(reloadCount ?? 0); + expect(axiosPatchCreditRequestDetails).toHaveBeenCalledWith({ + validationStatus, + issueAsModelYearReport: issueAsMY, + salesSubmissionComment: comment ? { comment } : undefined, + commentType: comment ? { govt: false } : undefined + }); + }); + }); + + + test("triggers comment-edit", async () => { + const newCommentIndex = 2; + const commentId = testComments[newCommentIndex].id; + const newComment = { id: commentId, comment: "New Test Comment", updateTimestamp: "2024-12-18T00:04:10" }; + + const axiosPatchCreditRequestUpdateComment = jest.fn(); + jest.spyOn(axios, "patch").mockImplementation((url, data) => { + expect(url).toBe(ROUTES_CREDIT_REQUESTS.UPDATE_COMMENT.replace(':id', commentId)); + axiosPatchCreditRequestUpdateComment(data); + return Promise.resolve({ data: newComment }); + }); + + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestDetailsContainer(baseProps); + await detailsPage.triggerInput(testIds.commentEdit, newComment); + expect(axiosPatchCreditRequestUpdateComment).toHaveBeenCalledWith({ comment: newComment.comment }); + + testComments[newCommentIndex] = newComment; + detailsPage.assertProps(baseExpectedDatailsPageProps); + }); + + + test("triggers comment-delete", async () => { + const deleteCommendIndex = 3; + const commentId = testComments[deleteCommendIndex].id; + + const axiosPatchCreditRequestDeleteComment = jest.fn(); + jest.spyOn(axios, "patch").mockImplementation((url) => { + expect(url).toBe(ROUTES_CREDIT_REQUESTS.DELETE_COMMENT.replace(':id', commentId)); + axiosPatchCreditRequestDeleteComment(); + return Promise.resolve({}); + }); + + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestDetailsContainer(baseProps); + await detailsPage.triggerInput(testIds.commentDelete, { id: commentId }); + expect(axiosPatchCreditRequestDeleteComment).toHaveBeenCalledTimes(1); + + testComments.splice(deleteCommendIndex, 1); + detailsPage.assertProps(baseExpectedDatailsPageProps); + }); +}); diff --git a/frontend/src/credits/__tests__/CreditRequestListContainer.test.js b/frontend/src/credits/__tests__/CreditRequestListContainer.test.js new file mode 100644 index 000000000..5e5f0eab2 --- /dev/null +++ b/frontend/src/credits/__tests__/CreditRequestListContainer.test.js @@ -0,0 +1,148 @@ +import React from "react"; +import { render, act, cleanup } from "@testing-library/react"; +import CreditRequestListContainer from "../CreditRequestListContainer"; +import * as CreditRequestsPage from "../components/CreditRequestsPage"; +import { MemoryRouter as Router, Route } from "react-router-dom"; +import axios from "axios"; +import ROUTES_CREDIT_REQUESTS from "../../app/routes/CreditRequests"; +import { baseUser, MockedComponent } from "./test-data/commonTestData"; + +const clearButtonTestId = "clear-button"; + +const baseProps = { + user: baseUser +}; + +const responseDataResults = [ + { id: 1, validationStatus: "DRAFT" }, + { id: 2, validationStatus: "ISSUED" }, + { id: 3, validationStatus: "SUBMITTED" }, + { id: 4, validationStatus: "SUBMITTED" }, +]; + +const baseExpectedDatailsPageProps = { + user: baseProps.user, + submissions: responseDataResults, + submissionsCount: responseDataResults.length, + page: 1, // default page number + pageSize: 10, // default page size + filters: [], + sorts: [], +}; + +const testParams = { + page: 3, + pageSize: 20, + queryFilters: [ + { "id": "field1", "value": "Item 1" }, + { "id": "field2", "value": "Item 2" }, + ], + stateFilters: [ + { "id": "field3", "value": "Item 3" }, + { "id": "field4", "value": "Item 4" }, + { "id": "field5", "value": "Item 5" }, + ], + sorts: [ + { "id": "field1", "desc": true }, + { "id": "field6", "desc": false }, + ] +} + +const renderCreditRequestListContainer = (props, state, query) => { + return act(async () => { + const search = query ? `?${query.map(x => `${x.id}=${x.value}`).join("&")}` : undefined; + render( + + + + + + ); + }); +}; + +class MockedDetailsPage extends MockedComponent { + constructor() { + super("Mocked Credit Request Details Page"); + jest.spyOn(CreditRequestsPage, "default").mockImplementation((props) => { + this.props = props; + return ( +
+
{this.mockedComponentText}
+
+ ); + }); + } +} + +// explicitly mock axios here instead of using the mock provided by jest-mock-axios, +// as that mock does not have the axios.spread function +jest.mock("axios", () => { + const originalModule = jest.requireActual("axios"); + return { __esModule: true, ...originalModule }; +}); + +beforeEach(() => { + jest.spyOn(axios, "post").mockImplementation((urlString) => { + const url = urlString.split("?")[0]; + expect(url).toEqual(ROUTES_CREDIT_REQUESTS.LIST_PAGINATED); + return Promise.resolve({ data: { + results: responseDataResults, + count: responseDataResults.length + } }); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + cleanup(); +}); + +describe("Credit Request List Container", () => { + test("renders CreditRequestValidatedDetailsPage with basic initial properties", async () => { + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestListContainer(baseProps); + detailsPage.assertRendered(); + detailsPage.assertProps(baseExpectedDatailsPageProps); + }); + + + test("renders CreditRequestValidatedDetailsPage with a specific page number", async () => { + const { page, pageSize } = testParams; + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestListContainer(baseProps, { page, pageSize }); + detailsPage.assertRendered(); + detailsPage.assertProps({ ...baseExpectedDatailsPageProps, page, pageSize }); + }); + + + test("renders CreditRequestValidatedDetailsPage with filter and sort", async () => { + const { queryFilters, stateFilters, sorts } = testParams; + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestListContainer(baseProps, { filters: stateFilters, sorts }, queryFilters); + detailsPage.assertRendered(); + detailsPage.assertProps({ + ...baseExpectedDatailsPageProps, + filters: [ ...queryFilters, ...stateFilters ], + sorts + }); + }); + + + test("handles clear", async () => { + const { page, pageSize, stateFilters, sorts } = testParams; + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestListContainer(baseProps, { page, pageSize, filters: stateFilters, sorts }); + detailsPage.assertRendered(); + detailsPage.assertProps({ + ...baseExpectedDatailsPageProps, + page, + pageSize, + filters: stateFilters, + sorts + }); + detailsPage.triggerInput(clearButtonTestId); + detailsPage.assertProps({ ...baseExpectedDatailsPageProps, pageSize }); + }); +}); \ No newline at end of file diff --git a/frontend/src/credits/__tests__/CreditRequestValidatedDetailsContainer.test.js b/frontend/src/credits/__tests__/CreditRequestValidatedDetailsContainer.test.js new file mode 100644 index 000000000..86fe98a6c --- /dev/null +++ b/frontend/src/credits/__tests__/CreditRequestValidatedDetailsContainer.test.js @@ -0,0 +1,130 @@ +import React from "react"; +import { render, act, cleanup } from "@testing-library/react"; +import CreditRequestValidatedDetailsContainer from "../CreditRequestValidatedDetailsContainer"; +import * as CreditRequestValidatedDetailsPage from "../components/CreditRequestValidatedDetailsPage"; +import { MemoryRouter as Router, Route } from "react-router-dom"; +import axios from "axios"; +import ROUTES_CREDIT_REQUESTS from "../../app/routes/CreditRequests"; +import { baseUser, testComments, MockedComponent } from "./test-data/commonTestData"; + +const creditRequestId = "12"; + +const baseProps = { + user: baseUser +}; + +const unselectedResponseData = [1, 3, 4]; + +const submissionResponseData = { + id: 8, + content: [], + salesSubmissionComment: testComments, + unselected: unselectedResponseData.length, + validationStatus: "VALIDATED", +}; + +const creditRequestContent = [ + { id: 1, salesDate: "2021-12-11T00:00:00" }, + { id: 2, salesDate: "2021-12-12T00:00:00" }, + { id: 3, salesDate: "2021-12-13T00:00:00" }, +]; + +const baseExpectedDatailsPageProps = { + invalidatedList: unselectedResponseData, + submission: submissionResponseData, + user: baseProps.user, + content: creditRequestContent, + itemsCount: creditRequestContent.length, + tableLoading: false, +}; + +const renderCreditRequestValidatedDetailsContainer = (props) => { + return act(async () => { + render( + + + + + + ); + }); +}; + +class MockedDetailsPage extends MockedComponent { + constructor() { + super("Mocked Credit Request Validated Details Page"); + jest.spyOn(CreditRequestValidatedDetailsPage, "default").mockImplementation((props) => { + this.props = props; + return
{this.mockedComponentText}
; + }); + } + + changeComment(comment) { + act(() => this.props.handleCommentChange(comment)); + } + + addComment() { + act(() => this.props.handleAddComment()); + } +} + +// explicitly mock axios here instead of using the mock provided by jest-mock-axios, +// as that mock does not have the axios.spread function +jest.mock("axios", () => { + const originalModule = jest.requireActual("axios"); + return { __esModule: true, ...originalModule }; +}); + +beforeEach(() => { + jest.spyOn(axios, "get").mockImplementation((url) => { + switch (url) { + case ROUTES_CREDIT_REQUESTS.DETAILS.replace(":id", creditRequestId) + "?skip_content=true": + return Promise.resolve({ data: submissionResponseData }); + case ROUTES_CREDIT_REQUESTS.UNSELECTED.replace(':id', creditRequestId): + return Promise.resolve({ data: unselectedResponseData }); + default: + throw new Error(`Unexpected URL: ${url}`); + } + }); + + jest.spyOn(axios, "post").mockImplementation((url) => { + expect(url).toEqual(ROUTES_CREDIT_REQUESTS.CONTENT.replace(":id", creditRequestId)); + return Promise.resolve({ data: { + content: creditRequestContent, + count: creditRequestContent.length + } }); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + cleanup(); +}); + +describe("Credit Request Validated Details Container", () => { + test("renders CreditRequestValidatedDetailsPage with basic initial properties", async () => { + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestValidatedDetailsContainer(baseProps); + detailsPage.assertRendered(); + detailsPage.assertProps(baseExpectedDatailsPageProps); + }); + + test("handle add-comment", async () => { + const axiosPatchCreditRequestDetails = jest.fn(); + jest.spyOn(axios, "patch").mockImplementation((url, data) => { + expect(url).toEqual(ROUTES_CREDIT_REQUESTS.DETAILS.replace(":id", creditRequestId)); + axiosPatchCreditRequestDetails(data); + return Promise.resolve({}); + }); + + const detailsPage = new MockedDetailsPage(); + await renderCreditRequestValidatedDetailsContainer(baseProps); + const newComment = "New Test Comment."; + detailsPage.changeComment(newComment); + detailsPage.addComment(); + expect(axiosPatchCreditRequestDetails).toHaveBeenCalledWith({ + salesSubmissionComment: { comment: newComment } + }); + detailsPage.assertProps(baseExpectedDatailsPageProps); + }); +}); diff --git a/frontend/src/credits/__tests__/test-data/commonTestData.js b/frontend/src/credits/__tests__/test-data/commonTestData.js new file mode 100644 index 000000000..26ee7c843 --- /dev/null +++ b/frontend/src/credits/__tests__/test-data/commonTestData.js @@ -0,0 +1,49 @@ +import { act, screen, fireEvent } from "@testing-library/react"; + + +export const baseUser = { + username: "TESTER", + hasPermission: () => {}, +} + + +export const testComments = [ + { id: 3, comment: "Test Comment 3" }, + { id: 4, comment: "Test Comment 4" }, + { id: 5, comment: "Test Comment 5" }, + { id: 6, comment: "Test Comment 6" }, + { id: 7, comment: "Test Comment 7" }, + { id: 8, comment: "Test Comment 8" }, + { id: 9, comment: "Test Comment 9" }, + { id: 10, comment: "Test Comment 10" }, +]; + + +export class MockedComponent { + constructor(mockedComponentText) { + this.props = undefined; + this.inputParams = undefined; + this.mockedComponentText = mockedComponentText; + } + + async triggerInput(testId, inputParams) { + this.inputParams = inputParams; + await act(async () => { + await fireEvent.click(screen.getByTestId(testId)); + }); + this.inputParams = undefined; + } + + assertRendered() { + const pageElements = screen.queryAllByText(this.mockedComponentText); + expect(pageElements).toHaveLength(1); + } + + assertProps(expectedProps) { + const actualProps = {}; + for (const key in expectedProps) { + actualProps[key] = this.props[key]; + } + expect(actualProps).toEqual(expectedProps); + } +} \ No newline at end of file diff --git a/frontend/src/credits/components/CreditRequestDetailsPage.js b/frontend/src/credits/components/CreditRequestDetailsPage.js index 24876978c..b91e43d60 100644 --- a/frontend/src/credits/components/CreditRequestDetailsPage.js +++ b/frontend/src/credits/components/CreditRequestDetailsPage.js @@ -126,7 +126,7 @@ const CreditRequestDetailsPage = (props) => { className="col-sm-11" rows="3" onChange={(event) => { - const commentValue = `

${event.target.value}

` + const commentValue = event.target.value.length === 0 ? "" : `

${event.target.value}

` setComment(commentValue) }} /> diff --git a/frontend/src/credits/components/CreditRequestListTable.js b/frontend/src/credits/components/CreditRequestListTable.js index 4a35b9de1..9c3f77a1b 100644 --- a/frontend/src/credits/components/CreditRequestListTable.js +++ b/frontend/src/credits/components/CreditRequestListTable.js @@ -13,6 +13,9 @@ import ROUTES_CREDIT_REQUESTS from '../../app/routes/CreditRequests' import calculateNumberOfPages from '../../app/utilities/calculateNumberOfPages' import CustomFilterComponent from '../../app/components/CustomFilterComponent' import moment from 'moment-timezone' +import ReactTooltip from 'react-tooltip' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { tooltipText } from '../constants/creditRequest' const CreditRequestListTable = (props) => { const { @@ -88,8 +91,16 @@ const CreditRequestListTable = (props) => { return item.totals.vins > 0 ? item.totals.vins : '-' }, className: 'text-right', - Header: 'Total Eligible ZEVs Supplied', - maxWidth: 150, + Header: () => ( +
+ + + + + Total Eligible ZEVs Supplied +
+ ), + maxWidth: 250, id: 'total-sales', filterable: false, sortable: false diff --git a/frontend/src/credits/components/CreditRequestSummaryTable.js b/frontend/src/credits/components/CreditRequestSummaryTable.js index a2bc8690c..1aa427205 100644 --- a/frontend/src/credits/components/CreditRequestSummaryTable.js +++ b/frontend/src/credits/components/CreditRequestSummaryTable.js @@ -8,12 +8,28 @@ import ReactTable from '../../app/components/ReactTable' import formatNumeric from '../../app/utilities/formatNumeric' import CustomPropTypes from '../../app/utilities/props' import isLegacySubmission from '../../app/utilities/isLegacySubmission' +import ReactTooltip from 'react-tooltip' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { tooltipText } from '../constants/creditRequest' const CreditRequestSummaryTable = (props) => { const columns = [ { headerClassName: 'header-group font-weight-bold', - Header: isLegacySubmission(props.submission) ? 'Consumer ZEV Sales' : 'ZEVs Supplied', + Header: () => { + if (isLegacySubmission(props.submission)) { + return 'Consumer ZEV Sales' + } + return ( +
+ + + + + ZEVs Supplied +
+ ) + }, columns: [ { headerClassName: 'd-none', diff --git a/frontend/src/credits/components/CreditTransfersAlert.js b/frontend/src/credits/components/CreditTransfersAlert.js index c1abec5b3..7a5877707 100644 --- a/frontend/src/credits/components/CreditTransfersAlert.js +++ b/frontend/src/credits/components/CreditTransfersAlert.js @@ -10,8 +10,13 @@ const CreditTransfersAlert = (props) => { let title let classname let icon = 'exclamation-circle' - const statusFilter = (transferStatus) => - history.filter((each) => each.status === transferStatus).reverse()[0] + const statusFilter = (transferStatus) => { + const filteredStatuses = history.filter((each) => each.status === transferStatus) + if (transferStatus === 'APPROVED') { + return filteredStatuses[0] + } + return filteredStatuses.reverse()[0] + } const date = moment(statusFilter(status).createTimestamp).format( 'MMM D, YYYY' ) diff --git a/frontend/src/credits/components/CreditTransfersDetailsActionBar.js b/frontend/src/credits/components/CreditTransfersDetailsActionBar.js index 4ef491ec8..c42ce5eed 100644 --- a/frontend/src/credits/components/CreditTransfersDetailsActionBar.js +++ b/frontend/src/credits/components/CreditTransfersDetailsActionBar.js @@ -10,6 +10,7 @@ const CreditTransfersDetailsActionBar = (props) => { assertions, checkboxes, comment, + existingComments, setModalType, setShowModal, transferRole, @@ -56,6 +57,8 @@ const CreditTransfersDetailsActionBar = (props) => { data-testid="recommend-reject-transfer" buttonType="reject" optionalText="Recommend Rejection" + disabled={existingComments.length === 0} + buttonTooltip="Please provide a comment for the director to enable this button." action={() => { setModalType('recommend-reject') setShowModal(true) @@ -97,7 +100,9 @@ const CreditTransfersDetailsActionBar = (props) => { +
+
) const tradePartnerSignoff = ( @@ -445,7 +447,7 @@ const CreditTransfersDetailsPage = (props) => { className="col-sm-11" rows="3" onChange={(event) => { - const commentValue = `

${event.target.value}

` + const commentValue = event.target.value.length === 0 ? "" : `

${event.target.value}

` setComment(commentValue) }} disabled={allChecked} @@ -529,6 +531,7 @@ const CreditTransfersDetailsPage = (props) => { assertions={assertions} checkboxes={checkboxes} comment={comment} + existingComments={transferCommentsIDIR} transferRole={transferRole} setModalType={setModalType} setShowModal={setShowModal} diff --git a/frontend/src/credits/components/ModelListTable.js b/frontend/src/credits/components/ModelListTable.js index d8e2e7a72..2f2f8660d 100644 --- a/frontend/src/credits/components/ModelListTable.js +++ b/frontend/src/credits/components/ModelListTable.js @@ -50,7 +50,7 @@ const ModelListTable = (props) => { return sum }, - Header: isLegacySubmission(submission) ? 'Sales Submitted' : 'Vehicles Submitted', + Header: isLegacySubmission(submission) ? 'Sales Submitted' : 'ZEVs Submitted', headerClassName: 'gap-left', id: 'sales', width: 150 diff --git a/frontend/src/credits/components/VINListTable.js b/frontend/src/credits/components/VINListTable.js index caa6dc226..724aaecef 100644 --- a/frontend/src/credits/components/VINListTable.js +++ b/frontend/src/credits/components/VINListTable.js @@ -323,6 +323,7 @@ const VINListTable = (props) => { page={page - 1} pages={calculateNumberOfPages(itemsCount, pageSize)} pageSize={pageSize} + minRows={1} sorted={sorts} filtered={filters} onPageChange={(pageIndex) => { diff --git a/frontend/src/credits/components/getAnalystRecommendationColumns.js b/frontend/src/credits/components/getAnalystRecommendationColumns.js index 0efc50b1e..d84aed1d8 100644 --- a/frontend/src/credits/components/getAnalystRecommendationColumns.js +++ b/frontend/src/credits/components/getAnalystRecommendationColumns.js @@ -1,4 +1,8 @@ +import React from 'react' import isLegacySubmission from "../../app/utilities/isLegacySubmission" +import ReactTooltip from 'react-tooltip' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { tooltipText } from '../constants/creditRequest' const analystRecommendationColumns = (props) => { const { submission, user } = props @@ -35,7 +39,20 @@ const analystRecommendationColumns = (props) => { return sum }, - Header: isLegacySubmission(submission) ? 'Eligible Sales' : 'Eligible ZEVs Supplied', + Header: () => { + if (isLegacySubmission(submission)) { + return 'Eligible Sales' + } + return ( +
+ + + + + Eligible ZEVs Supplied +
+ ) + }, headerClassName: ' eligible-sales', id: 'eligible-sales', show: user.isGovernment, diff --git a/frontend/src/credits/constants/creditRequest.js b/frontend/src/credits/constants/creditRequest.js new file mode 100644 index 000000000..729d6e083 --- /dev/null +++ b/frontend/src/credits/constants/creditRequest.js @@ -0,0 +1,5 @@ +const tooltipText = `As of October 1, 2024, total eligible ZEV sales converted to total eligible
+ ZEVs supplied to align with amendments to the ZEV Act. Note that MY
+ 2023 ZEVs and earlier is based on suppliers' sales information.`; + +export { tooltipText }; diff --git a/frontend/src/organizations/VehicleSupplierDetailsContainer.js b/frontend/src/organizations/VehicleSupplierDetailsContainer.js index 2ebc351af..c73f7f601 100644 --- a/frontend/src/organizations/VehicleSupplierDetailsContainer.js +++ b/frontend/src/organizations/VehicleSupplierDetailsContainer.js @@ -98,7 +98,7 @@ const VehicleSupplierDetailsContainer = (props) => { axios .put(ROUTES_ORGANIZATIONS.LDV_SALES.replace(/:id/gi, id), { - ...fields + ...fields, isSupplied: true }) .then(() => { History.push(ROUTES_ORGANIZATIONS.LIST) diff --git a/frontend/src/organizations/components/VehicleSupplierDetailsPage.js b/frontend/src/organizations/components/VehicleSupplierDetailsPage.js index a5ea5b6b1..31dc15354 100644 --- a/frontend/src/organizations/components/VehicleSupplierDetailsPage.js +++ b/frontend/src/organizations/components/VehicleSupplierDetailsPage.js @@ -1,12 +1,13 @@ -import React from 'react' -import PropTypes from 'prop-types' - -import Button from '../../app/components/Button' -import Loading from '../../app/components/Loading' -import CustomPropTypes from '../../app/utilities/props' -import ROUTES_ORGANIZATIONS from '../../app/routes/Organizations' -import VehicleSupplierClass from './VehicleSupplierClass' -import formatNumeric from '../../app/utilities/formatNumeric' +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import Button from "../../app/components/Button"; +import Loading from "../../app/components/Loading"; +import CustomPropTypes from "../../app/utilities/props"; +import ROUTES_ORGANIZATIONS from "../../app/routes/Organizations"; +import VehicleSupplierClass from "./VehicleSupplierClass"; +import formatNumeric from "../../app/utilities/formatNumeric"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Tooltip from "../../app/components/Tooltip"; const VehicleSupplierDetailsPage = (props) => { const { @@ -22,17 +23,31 @@ const VehicleSupplierDetailsPage = (props) => { selectedModelYear, handleDeleteSale, isEditable, - setIsEditable - } = props - const { organizationAddress } = details - + setIsEditable, + } = props; + const { organizationAddress } = details; + const [showAllSales, setShowAllSales] = useState(false); + const [showAllSupplied, setShowAllSupplied] = useState(false); + const filteredSales = ldvSales.filter((sale) => sale.isSupplied === false); + const filteredSupplied = ldvSales.filter((sale) => sale.isSupplied === true); + const salesToShow = showAllSales ? filteredSales : filteredSales.slice(0, 3); + const suppliedToShow = showAllSupplied + ? filteredSupplied + : filteredSupplied.slice(0, 3); if (loading) { - return + return ; } + const salesTooltip = `Supplier's total vehicle sales for each reportable model year's + compliance period (October 1st - September 30th). Read-only historical record.`; + const suppliedTooltip = `Average supply volume is defined in section 8 + of the ZEV Regulation. Motor vehicles supplied before October 1, 2024, that + have a gross vehicle weight rating of more than 3,856 kg are not included.`; const renderAddressType = (type) => { if (organizationAddress) { - const addresses = organizationAddress.filter(address => address.addressType.addressType === type) + const addresses = organizationAddress.filter( + (address) => address.addressType.addressType === type, + ); return addresses.map((address) => { return (
@@ -42,15 +57,15 @@ const VehicleSupplierDetailsPage = (props) => {
{address.addressLine1}
{address.addressLine2}
- {' '} - {address.city} {address.state} {address.country}{' '} + {" "} + {address.city} {address.state} {address.country}{" "}
{address.postalCode}
- ) - }) + ); + }); } - } + }; return (
@@ -70,42 +85,39 @@ const VehicleSupplierDetailsPage = (props) => {

Status:

- {' '} + {" "} {details.isActive - ? 'Actively supplying vehicles in British Columbia' - : 'Not actively supplying vehicles in British Columbia'}{' '} + ? "Actively supplying vehicles in British Columbia" + : "Not actively supplying vehicles in British Columbia"}{" "}

Service Address

- {renderAddressType('Service')} + {renderAddressType("Service")}

Records Address

- {renderAddressType('Records')} + {renderAddressType("Records")}

Vehicle Supplier Class:

- {' '} + {" "} {' '} + />{" "}

First Model Year Report:

- - {' '}{details.firstModelYear}{' '} - + {details.firstModelYear}
-

3 Year Average Vehicles Supplied:

{formatNumeric(Math.round(details.avgLdvSales), 0)} @@ -114,12 +126,19 @@ const VehicleSupplierDetailsPage = (props) => {
{!details.hasSubmittedReport && (
- Enter the previous 3 year vehicles supplied total to determine vehicle - supplier class. + Enter the previous 3 year vehicles supplied total to determine + vehicle supplier class.
)} {details.hasSubmittedReport && ( -

Previous 3 Year Vehicles Supplied:

+ + +

Previous 3 Year Vehicles Supplied:

+
)}
@@ -160,56 +179,103 @@ const VehicleSupplierDetailsPage = (props) => { Add - {!isEditable - ? ( + {!isEditable ? ( - ) - : ( + ) : ( - )} + )}
- -
    - {ldvSales.map((sale) => ( -
  • -
    - {sale.modelYear} Model Year: -
    -
    - {formatNumeric(sale.ldvSales, 0)} + {suppliedToShow.length > 0 && ( +
      + {suppliedToShow.map((sale) => ( +
    • +
      + {sale.modelYear} Model Year: +
      +
      + {formatNumeric(sale.ldvSales, 0)} +
      +
      + {isEditable && ( + + )} +
      +
    • + ))} + {filteredSupplied.length > 3 && ( +
      +
      -
      - {isEditable && ( - - )} + )} +
    + )} + {salesToShow.length > 0 && ( +
      + + + Previous Years Vehicle Sales + + {salesToShow.length > 0 && + salesToShow.map((sale) => ( +
    • +
      + {sale.modelYear} Model Year: +
      +
      + {formatNumeric(sale.ldvSales, 0)} +
      +
    • + ))} + {filteredSales.length > 3 && ( +
      +
      - - ))} -
    + )} +
+ )}
@@ -232,14 +298,14 @@ const VehicleSupplierDetailsPage = (props) => { - ) -} + ); +}; VehicleSupplierDetailsPage.defaultProps = { - inputLDVSales: '', + inputLDVSales: "", locationState: undefined, - selectedModelYear: '' -} + selectedModelYear: "", +}; VehicleSupplierDetailsPage.propTypes = { details: CustomPropTypes.organizationDetails.isRequired, @@ -252,7 +318,7 @@ VehicleSupplierDetailsPage.propTypes = { handleInputChange: PropTypes.func.isRequired, handleSubmit: PropTypes.func.isRequired, inputLDVSales: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - selectedModelYear: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) -} + selectedModelYear: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; -export default VehicleSupplierDetailsPage +export default VehicleSupplierDetailsPage; diff --git a/frontend/src/salesforecast/components/RecordsUpload.js b/frontend/src/salesforecast/components/RecordsUpload.js index 889d15871..2f26df087 100644 --- a/frontend/src/salesforecast/components/RecordsUpload.js +++ b/frontend/src/salesforecast/components/RecordsUpload.js @@ -195,13 +195,17 @@ const RecordsUpload = ({ currentModelYear, setRecords, setTotals }) => { className="report-checkmark" /> Your file has been successfully uploaded. Please review - the table below before saving. + the table below before saving. To upload another document, + please delete the existing one by clicking the ‘DELETE’ button + located next to the file name and size. )} diff --git a/frontend/src/supplementary/components/UploadEvidence.js b/frontend/src/supplementary/components/UploadEvidence.js index 6eecf5093..206001b54 100644 --- a/frontend/src/supplementary/components/UploadEvidence.js +++ b/frontend/src/supplementary/components/UploadEvidence.js @@ -1,17 +1,16 @@ -import React from 'react' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import axios from 'axios' -import ExcelFileDrop from '../../app/components/FileDrop' -import getFileSize from '../../app/utilities/getFileSize' +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import axios from "axios"; +import ExcelFileDrop from "../../app/components/FileDrop"; +import getFileSize from "../../app/utilities/getFileSize"; const UploadEvidence = (props) => { - const { details, setUploadFiles, files, setDeleteFiles, deleteFiles } = props + const { details, setUploadFiles, files, setDeleteFiles, deleteFiles } = props; const removeFile = (removedFile) => { - const found = files.findIndex((file) => file === removedFile) - files.splice(found, 1) - setUploadFiles([...files]) - } + const updatedFiles = files.filter((file) => file !== removedFile); + setUploadFiles(updatedFiles); + }; return ( <> @@ -47,21 +46,21 @@ const UploadEvidence = (props) => { onClick={() => { axios .get(attachment.url, { - responseType: 'blob', + responseType: "blob", headers: { - Authorization: null - } + Authorization: null, + }, }) .then((response) => { const objectURL = window.URL.createObjectURL( - new Blob([response.data]) - ) - const link = document.createElement('a') - link.href = objectURL - link.setAttribute('download', attachment.filename) - document.body.appendChild(link) - link.click() - }) + new Blob([response.data]), + ); + const link = document.createElement("a"); + link.href = objectURL; + link.setAttribute("download", attachment.filename); + document.body.appendChild(link); + link.click(); + }); }} type="button" > @@ -75,7 +74,7 @@ const UploadEvidence = (props) => {