diff --git a/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py b/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py index acb682b59a..ad3d3d8869 100644 --- a/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py +++ b/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py @@ -181,6 +181,7 @@ class TestEndpointPermissions(TestCase): {"method": "patch", "endpoint_name": "operation_bcghg_id", "kwargs": {"operation_id": mock_uuid}}, {"method": "patch", "endpoint_name": "facility_bcghg_id", "kwargs": {'facility_id': mock_uuid}}, {"method": "patch", "endpoint_name": "operation_bcghg_id", "kwargs": {'operation_id': mock_uuid}}, + {"method": "get", "endpoint_name": "list_transfer_events"}, ], "approved_authorized_roles": [ {"method": "get", "endpoint_name": "list_operations"}, diff --git a/bc_obps/registration/api/v1/__init__.py b/bc_obps/registration/api/v1/__init__.py index 3321019fbd..8e139e0658 100644 --- a/bc_obps/registration/api/v1/__init__.py +++ b/bc_obps/registration/api/v1/__init__.py @@ -13,6 +13,7 @@ users, facilities, contacts, + transfer_events, ) from ._operations import operation_id from ._operations._operation_id import update_status, facilities, operation_representatives diff --git a/bc_obps/registration/api/v1/transfer_events.py b/bc_obps/registration/api/v1/transfer_events.py new file mode 100644 index 0000000000..41789e1648 --- /dev/null +++ b/bc_obps/registration/api/v1/transfer_events.py @@ -0,0 +1,36 @@ +from typing import List, Literal, Optional +from registration.models.event.transfer_event import TransferEvent +from registration.schema.v1.transfer_event import TransferEventFilterSchema, TransferEventListOut +from service.transfer_event_service import TransferEventService +from common.permissions import authorize +from django.http import HttpRequest +from registration.utils import CustomPagination +from registration.constants import TRANSFER_EVENT_TAGS +from ninja.pagination import paginate +from registration.decorators import handle_http_errors +from ..router import router +from service.error_service.custom_codes_4xx import custom_codes_4xx +from ninja import Query +from registration.schema.generic import Message +from django.db.models import QuerySet + + +@router.get( + "/transfer-events", + response={200: List[TransferEventListOut], custom_codes_4xx: Message}, + tags=TRANSFER_EVENT_TAGS, + description="""Retrieves a paginated list of transfer events based on the provided filters. + The endpoint allows authorized users to view and sort transfer events filtered by various criteria such as operation, facility, and status.""", + auth=authorize("authorized_irc_user"), +) +@handle_http_errors() +@paginate(CustomPagination) +def list_transfer_events( + request: HttpRequest, + filters: TransferEventFilterSchema = Query(...), + sort_field: Optional[str] = "status", + sort_order: Optional[Literal["desc", "asc"]] = "desc", + paginate_result: bool = Query(True, description="Whether to paginate the results"), +) -> QuerySet[TransferEvent]: + # NOTE: PageNumberPagination raises an error if we pass the response as a tuple (like 200, ...) + return TransferEventService.list_transfer_events(sort_field, sort_order, filters) diff --git a/bc_obps/registration/constants.py b/bc_obps/registration/constants.py index 15b8e057c7..0856a17e61 100644 --- a/bc_obps/registration/constants.py +++ b/bc_obps/registration/constants.py @@ -29,4 +29,5 @@ USER_OPERATOR_TAGS_V2 = ["User Operator V2"] MISC_TAGS = ["Misc V1"] CONTACT_TAGS = ["Contact V1"] +TRANSFER_EVENT_TAGS = ["Transfer Event V1"] V2 = ["V2"] diff --git a/bc_obps/registration/fixtures/mock/transfer_event.json b/bc_obps/registration/fixtures/mock/transfer_event.json index 3e0adca234..d352d38761 100644 --- a/bc_obps/registration/fixtures/mock/transfer_event.json +++ b/bc_obps/registration/fixtures/mock/transfer_event.json @@ -11,8 +11,7 @@ "description": "Sold my operation to the other operator so that I could move to Hawaii. Don't tell my employees they don't know.", "status": "Transferred", "other_operator": "4242ea9d-b917-4129-93c2-db00b7451051", - "other_operator_contact": 13, - "future_designated_operator": "Other Operator" + "other_operator_contact": 13 } }, { @@ -24,8 +23,7 @@ "effective_date": "2024-08-21T09:00:00Z", "description": "I was the designated operator but I'm being relieved of my duties because my other operators don't trust me anymore. Transfer this operation over to another one of the operators in our multiple-operators relationship. See if you can do better Mia!", "other_operator": "4242ea9d-b917-4129-93c2-db00b7451051", - "other_operator_contact": 15, - "future_designated_operator": "Other Operator" + "other_operator_contact": 15 } }, { @@ -43,8 +41,7 @@ "effective_date": "2024-12-25T09:00:00Z", "description": "I don't know what I'm doing but this seems like the right thing to do.", "other_operator": "4242ea9d-b917-4129-93c2-db00b7451051", - "other_operator_contact": 15, - "future_designated_operator": "Not sure" + "other_operator_contact": 15 } } ] diff --git a/bc_obps/registration/migrations/0056_remove_historicaltransferevent_future_designated_operator_and_more.py b/bc_obps/registration/migrations/0056_remove_historicaltransferevent_future_designated_operator_and_more.py new file mode 100644 index 0000000000..6f4928c812 --- /dev/null +++ b/bc_obps/registration/migrations/0056_remove_historicaltransferevent_future_designated_operator_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.0.9 on 2024-11-15 23:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registration', '0055_V1_14_0'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicaltransferevent', + name='future_designated_operator', + ), + migrations.RemoveField( + model_name='transferevent', + name='future_designated_operator', + ), + migrations.AlterField( + model_name='historicaltransferevent', + name='status', + field=models.CharField( + choices=[ + ('Complete', 'Complete'), + ('To be transferred', 'To Be Transferred'), + ('Transferred', 'Transferred'), + ], + default='To be transferred', + max_length=100, + ), + ), + migrations.AlterField( + model_name='transferevent', + name='status', + field=models.CharField( + choices=[ + ('Complete', 'Complete'), + ('To be transferred', 'To Be Transferred'), + ('Transferred', 'Transferred'), + ], + default='To be transferred', + max_length=100, + ), + ), + ] diff --git a/bc_obps/registration/models/event/transfer_event.py b/bc_obps/registration/models/event/transfer_event.py index b3f7dd993a..ffd6a7bef1 100644 --- a/bc_obps/registration/models/event/transfer_event.py +++ b/bc_obps/registration/models/event/transfer_event.py @@ -5,22 +5,13 @@ class TransferEvent(EventBaseModel): + # ok to display the db ones in the grid class Statuses(models.TextChoices): COMPLETE = "Complete" - PENDING = "Pending" + TO_BE_TRANSFERRED = "To be transferred" TRANSFERRED = "Transferred" - class FutureDesignatedOperatorChoices(models.TextChoices): - MY_OPERATOR = "My Operator" - OTHER_OPERATOR = "Other Operator" - NOT_SURE = "Not Sure" - description = models.TextField(db_comment="Description of the transfer or change in designated operator.") - future_designated_operator = models.CharField( - max_length=1000, - choices=FutureDesignatedOperatorChoices.choices, - db_comment="The designated operator of the entit(y)/(ies) associated with the transfer, who will be responsible for matters related to GGERR.", - ) other_operator = models.ForeignKey( Operator, on_delete=models.PROTECT, @@ -36,7 +27,7 @@ class FutureDesignatedOperatorChoices(models.TextChoices): status = models.CharField( max_length=100, choices=Statuses.choices, - default=Statuses.PENDING, + default=Statuses.TO_BE_TRANSFERRED, ) history = HistoricalRecords( table_name='erc_history"."transfer_event_history', diff --git a/bc_obps/registration/schema/v1/transfer_event.py b/bc_obps/registration/schema/v1/transfer_event.py new file mode 100644 index 0000000000..b11801472e --- /dev/null +++ b/bc_obps/registration/schema/v1/transfer_event.py @@ -0,0 +1,52 @@ +from typing import Optional +from uuid import UUID + +from registration.models.event.transfer_event import TransferEvent +from ninja import ModelSchema, Field, FilterSchema +from django.db.models import Q +import re +from typing import Dict, Any + + +class TransferEventListOut(ModelSchema): + operation__id: Optional[UUID] = None + operation__name: Optional[str] = Field(None, alias="operation__name") + facilities__name: Optional[str] = Field(None, alias="facilities__name") + facility__id: Optional[UUID] = Field(None, alias="facilities__id") + id: UUID + + @staticmethod + def resolve_id(obj: Dict[str, Any]) -> UUID: + operation_id = obj.get('operation__id', None) + facility_id = obj.get('facilities__id', None) + + record_id = operation_id if operation_id else facility_id + if not isinstance(record_id, UUID): + raise Exception('Missing valid UUID') + return record_id + + class Meta: + model = TransferEvent + fields = ['effective_date', 'status', 'created_at'] + + +class TransferEventFilterSchema(FilterSchema): + # NOTE: we could simply use the `q` parameter to filter by related fields but, + # due to this issue: https://github.com/vitalik/django-ninja/issues/1037 mypy is unhappy so I'm using the `json_schema_extra` parameter + # If we want to achieve more by using the `q` parameter, we should use it and ignore the mypy error + effective_date: Optional[str] = Field(None, json_schema_extra={'q': 'effective_date__icontains'}) + operation__name: Optional[str] = None + facilities__name: Optional[str] = Field(None, json_schema_extra={'q': 'facilities__name__icontains'}) + status: Optional[str] = Field(None, json_schema_extra={'q': 'status__icontains'}) + + @staticmethod + def filtering_including_not_applicable(field: str, value: str) -> Q: + if value and re.search(value, 'n/a', re.IGNORECASE): + return Q(**{f"{field}__icontains": value}) | Q(**{f"{field}__isnull": True}) + return Q(**{f"{field}__icontains": value}) if value else Q() + + def filter_operation__name(self, value: str) -> Q: + return self.filtering_including_not_applicable('operation__name', value) + + def filter_facilities__name(self, value: str) -> Q: + return self.filtering_including_not_applicable('facilities__name', value) diff --git a/bc_obps/registration/tests/endpoints/v1/test_transfer_events.py b/bc_obps/registration/tests/endpoints/v1/test_transfer_events.py new file mode 100644 index 0000000000..c598ccd5ad --- /dev/null +++ b/bc_obps/registration/tests/endpoints/v1/test_transfer_events.py @@ -0,0 +1,138 @@ +from datetime import datetime, timedelta +from django.utils import timezone +from bc_obps.settings import NINJA_PAGINATION_PER_PAGE +from model_bakery import baker +from registration.tests.utils.helpers import CommonTestSetup, TestUtils + +from registration.utils import custom_reverse_lazy + + +class TestTransferEventEndpoint(CommonTestSetup): + url = custom_reverse_lazy('list_transfer_events') + # GET + def test_list_transfer_events_unpaginated(self): + # transfer of an operation + baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) + # transfer of 50 facilities + baker.make_recipe('utils.transfer_event', facilities=baker.make_recipe('utils.facility', _quantity=50)) + for role in ['cas_admin', 'cas_analyst']: + response = TestUtils.mock_get_with_auth_role(self, role, self.url + "?paginate_result=False") + assert response.status_code == 200 + assert response.json().keys() == {'count', 'items'} + assert response.json()['count'] == 51 + + items = response.json().get('items', []) + for item in items: + assert set(item.keys()) == { + 'operation__name', + 'operation__id', + 'effective_date', + 'status', + 'id', + 'facilities__name', + 'facility__id', + 'created_at', + } + + def test_list_transfer_events_paginated(self): + # transfer of an operation + baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) + # transfer of 50 facilities + baker.make_recipe('utils.transfer_event', facilities=baker.make_recipe('utils.facility', _quantity=50)) + # Get the default page 1 response + response = TestUtils.mock_get_with_auth_role(self, "cas_admin", custom_reverse_lazy("list_transfer_events")) + assert response.status_code == 200 + + response_items_1 = response.json().get('items') + response_count_1 = response.json().get('count') + # save the id of the first paginated response item + page_1_response_id = response_items_1[0].get('id') + assert len(response_items_1) == NINJA_PAGINATION_PER_PAGE + assert response_count_1 == 51 # total count of transfers + # Get the page 2 response + response = TestUtils.mock_get_with_auth_role( + self, + "cas_admin", + self.url + "?page=2&sort_field=created_at&sort_order=desc", + ) + assert response.status_code == 200 + response_items_2 = response.json().get('items') + response_count_2 = response.json().get('count') + # save the id of the first paginated response item + page_2_response_id = response_items_2[0].get('id') + assert len(response_items_2) == NINJA_PAGINATION_PER_PAGE + # assert that the first item in the page 1 response is not the same as the first item in the page 2 response + assert page_1_response_id != page_2_response_id + assert response_count_2 == response_count_1 # total count of transfer_events should be the same + # Get the page 2 response but with a different sort order + response = TestUtils.mock_get_with_auth_role( + self, + "cas_admin", + self.url + "?page=2&sort_field=created_at&sort_order=asc", + ) + assert response.status_code == 200 + response_items_2_reverse = response.json().get('items') + # save the id of the first paginated response item + page_2_response_id_reverse = response_items_2_reverse[0].get('id') + assert len(response_items_2_reverse) == NINJA_PAGINATION_PER_PAGE + # assert that the first item in the page 2 response is not the same as the first item in the page 2 response with reversed order + assert page_2_response_id != page_2_response_id_reverse + + def test_transfer_events_endpoint_list_transfer_events_with_sorting(self): + today = timezone.make_aware(datetime.now()) + yesterday = today - timedelta(days=1) + # transfer of an operation + baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation'), effective_date=today) + # transfer of 50 facilities + baker.make_recipe( + 'utils.transfer_event', + effective_date=yesterday, + facilities=baker.make_recipe('utils.facility', _quantity=50), + ) + + response_ascending = TestUtils.mock_get_with_auth_role( + self, "cas_admin", self.url + "?page=1&sort_field=effective_date&sort_order=asc" + ) + # save the id of the first paginated response item + first_item_ascending = response_ascending.json()['items'][0] + + # # Sort created at descending + response_descending = TestUtils.mock_get_with_auth_role( + self, "cas_admin", self.url + "?page=1&sort_field=effective_date&sort_order=desc" + ) + first_item_descending = response_descending.json()['items'][0] + assert first_item_descending['effective_date'] > first_item_ascending['effective_date'] + + def test_transfer_events_endpoint_list_transfer_events_with_filter(self): + # transfer of an operation + baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation', name='Test Operation')) + # transfer of 50 facilities + baker.make_recipe('utils.transfer_event', facilities=baker.make_recipe('utils.facility', _quantity=50)) + + # Get the default page 1 response + response = TestUtils.mock_get_with_auth_role( + self, "cas_admin", self.url + "?facilities__name=010" + ) # filtering facilities__name + assert response.status_code == 200 + response_items_1 = response.json().get('items') + for item in response_items_1: + assert item.get('facilities__name') == "Facility 010" + + # Test with a status filter that doesn't exist + response = TestUtils.mock_get_with_auth_role(self, "cas_admin", self.url + "?status=unreal") + assert response.status_code == 200 + assert response.json().get('count') == 0 + + # Test with two filters + facilities__name_to_filter, status_to_filter = response_items_1[0].get('facilities__name'), response_items_1[ + 0 + ].get('status') + response = TestUtils.mock_get_with_auth_role( + self, "cas_admin", self.url + f"?facilities__name={facilities__name_to_filter}&status={status_to_filter}" + ) + assert response.status_code == 200 + response_items_2 = response.json().get('items') + assert len(response_items_2) == 1 + assert response.json().get('count') == 1 + assert response_items_2[0].get('facilities__name') == facilities__name_to_filter + assert response_items_2[0].get('status') == status_to_filter diff --git a/bc_obps/registration/tests/models/event/test_transfer.py b/bc_obps/registration/tests/models/event/test_transfer.py index 19754134e4..149dc53693 100644 --- a/bc_obps/registration/tests/models/event/test_transfer.py +++ b/bc_obps/registration/tests/models/event/test_transfer.py @@ -42,14 +42,12 @@ def setUpTestData(cls): ("facilities", "facilities", None, None), ("other_operator", "other operator", None, None), ("other_operator_contact", "other operator contact", None, None), - ("future_designated_operator", "future designated operator", None, None), ] super().setUpTestData() def test_event_with_operation_only(self): self.create_event_with_operation_only( description="Why the transfer is happening", - future_designated_operator="My Operator", other_operator=operator_baker(), other_operator_contact=contact_baker(), ) @@ -57,7 +55,6 @@ def test_event_with_operation_only(self): def test_event_with_facilities_only(self): self.create_event_with_facilities_only( description="Why the transfer is happening returns", - future_designated_operator="My Operator", other_operator=operator_baker(), other_operator_contact=contact_baker(), ) diff --git a/bc_obps/registration/tests/utils/baker_recipes.py b/bc_obps/registration/tests/utils/baker_recipes.py index 708e87d00a..6f8d2c2a87 100644 --- a/bc_obps/registration/tests/utils/baker_recipes.py +++ b/bc_obps/registration/tests/utils/baker_recipes.py @@ -138,7 +138,6 @@ transfer_event = Recipe( TransferEvent, - future_designated_operator=TransferEvent.FutureDesignatedOperatorChoices.MY_OPERATOR, other_operator=foreign_key(other_operator_for_transfer_event), other_operator_contact=foreign_key(contact_for_transfer_event), ) diff --git a/bc_obps/service/tests/test_transfer_event_service.py b/bc_obps/service/tests/test_transfer_event_service.py new file mode 100644 index 0000000000..607456f5c6 --- /dev/null +++ b/bc_obps/service/tests/test_transfer_event_service.py @@ -0,0 +1,22 @@ +from registration.schema.v1.transfer_event import TransferEventFilterSchema +from service.transfer_event_service import TransferEventService +import pytest +from model_bakery import baker + +pytestmark = pytest.mark.django_db + + +class TestTransferEventService: + @staticmethod + def test_list_transfer_events(): + # transfer of 3 operations + baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation'), _quantity=3) + # transfer of 4 facilities + baker.make_recipe('utils.transfer_event', facilities=baker.make_recipe('utils.facility', _quantity=4)) + # sorting and filtering are tested in the endpoint test in conjunction with pagination + result = TransferEventService.list_transfer_events( + "status", + "desc", + TransferEventFilterSchema(effective_date=None, operation__name=None, facilities__name=None, status=None), + ) + assert result.count() == 7 diff --git a/bc_obps/service/transfer_event_service.py b/bc_obps/service/transfer_event_service.py new file mode 100644 index 0000000000..70680dc531 --- /dev/null +++ b/bc_obps/service/transfer_event_service.py @@ -0,0 +1,32 @@ +from typing import cast +from django.db.models import QuerySet +from registration.models.event.transfer_event import TransferEvent +from typing import Optional +from registration.schema.v1.transfer_event import TransferEventFilterSchema +from ninja import Query + + +class TransferEventService: + @classmethod + def list_transfer_events( + cls, + sort_field: Optional[str], + sort_order: Optional[str], + filters: TransferEventFilterSchema = Query(...), + ) -> QuerySet[TransferEvent]: + sort_direction = "-" if sort_order == "desc" else "" + sort_by = f"{sort_direction}{sort_field}" + queryset = ( + filters.filter(TransferEvent.objects.order_by(sort_by)) + .values( + 'effective_date', + 'status', + 'created_at', + 'operation__name', + 'operation__id', + 'facilities__name', + 'facilities__id', + ) + .distinct() + ) + return cast(QuerySet[TransferEvent], queryset) diff --git a/bciers/apps/administration/app/components/operations/OperationDataGridPage.tsx b/bciers/apps/administration/app/components/operations/OperationDataGridPage.tsx index 8cbf325c05..c14369a504 100644 --- a/bciers/apps/administration/app/components/operations/OperationDataGridPage.tsx +++ b/bciers/apps/administration/app/components/operations/OperationDataGridPage.tsx @@ -20,9 +20,8 @@ export default async function OperationDataGridPage({ rows: OperationRow[]; row_count: number; } = await fetchOperationsPageData(searchParams); - if (!operations) { - return
No operations data in database.
; - } + if (!operations || "error" in operations) + throw new Error("Failed to retrieve operations"); const isAuthorizedAdminUser = [ FrontEndRoles.CAS_ADMIN, diff --git a/bciers/apps/administration/app/components/operators/OperatorDataGridPage.tsx b/bciers/apps/administration/app/components/operators/OperatorDataGridPage.tsx index 2ec985815c..569047c578 100644 --- a/bciers/apps/administration/app/components/operators/OperatorDataGridPage.tsx +++ b/bciers/apps/administration/app/components/operators/OperatorDataGridPage.tsx @@ -15,9 +15,8 @@ export default async function Operators({ rows: OperatorRow[]; row_count: number; } = await fetchOperatorsPageData(searchParams); - if (!operators) { - return
No operator data in database.
; - } + if (!operators || "error" in operators) + throw new Error("Failed to retrieve operators"); // Render the DataGrid component return ( diff --git a/bciers/apps/administration/tests/components/operations/OperationDataGridPage.test.tsx b/bciers/apps/administration/tests/components/operations/OperationDataGridPage.test.tsx index 5e3dc37e6d..b09bf991c5 100644 --- a/bciers/apps/administration/tests/components/operations/OperationDataGridPage.test.tsx +++ b/bciers/apps/administration/tests/components/operations/OperationDataGridPage.test.tsx @@ -51,11 +51,12 @@ describe("Operations component", () => { vi.clearAllMocks(); }); - it("renders a message when there are no operations in the database", async () => { + it("throws an error when there's a problem fetching data", async () => { fetchOperationsPageData.mockReturnValueOnce(undefined); - render(await Operations({ searchParams: {} })); + await expect(async () => { + render(await Operations({ searchParams: {} })); + }).rejects.toThrow("Failed to retrieve operations"); expect(screen.queryByRole("grid")).not.toBeInTheDocument(); - expect(screen.getByText(/No operations data in database./i)).toBeVisible(); }); it("renders the OperationDataGrid component when there are operations in the database", async () => { diff --git a/bciers/apps/administration/tests/components/operators/OperatorDataGridPage.test.tsx b/bciers/apps/administration/tests/components/operators/OperatorDataGridPage.test.tsx index b9122f83af..e1ce46058d 100644 --- a/bciers/apps/administration/tests/components/operators/OperatorDataGridPage.test.tsx +++ b/bciers/apps/administration/tests/components/operators/OperatorDataGridPage.test.tsx @@ -54,11 +54,13 @@ describe("OperatorDataGridPage component", () => { vi.clearAllMocks(); }); - it("renders a message when there are no operators in the database", async () => { + it("throws an error when there's a problem fetching data", async () => { fetchOperatorsPageData.mockReturnValueOnce(undefined); - render(await Operators({ searchParams: {} })); + await expect(async () => { + render(await Operators({ searchParams: {} })); + }).rejects.toThrow("Failed to retrieve operators"); + expect(screen.queryByRole("grid")).not.toBeInTheDocument(); - expect(screen.getByText(/No operator data in database./i)).toBeVisible(); }); it("renders the OperatorDataGrid component when there are operators in the database", async () => { diff --git a/bciers/apps/registration/app/components/datagrid/models/transfers/transferColumns.ts b/bciers/apps/registration/app/components/datagrid/models/transfers/transferColumns.ts new file mode 100644 index 0000000000..0a36dd3eb4 --- /dev/null +++ b/bciers/apps/registration/app/components/datagrid/models/transfers/transferColumns.ts @@ -0,0 +1,36 @@ +import { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; + +const transferColumns = ( + ActionCell: (params: GridRenderCellParams) => JSX.Element, +) => { + const columns: GridColDef[] = [ + { + field: "created_at", + headerName: "Submission Date", + width: 200, + }, + { field: "operation__name", headerName: "Operation", width: 200 }, + { field: "facilities__name", headerName: "Facility", flex: 1 }, + { + field: "status", + headerName: "Status", + width: 200, + }, + { + field: "effective_date", + headerName: "Effective Date", + width: 200, + }, + { + field: "action", + headerName: "Actions", + renderCell: ActionCell, + sortable: false, + width: 120, + }, + ]; + + return columns; +}; + +export default transferColumns; diff --git a/bciers/apps/registration/app/components/datagrid/models/transfers/transferGroupColumns.ts b/bciers/apps/registration/app/components/datagrid/models/transfers/transferGroupColumns.ts new file mode 100644 index 0000000000..d635ebee8d --- /dev/null +++ b/bciers/apps/registration/app/components/datagrid/models/transfers/transferGroupColumns.ts @@ -0,0 +1,52 @@ +import { + GridColumnGroupHeaderParams, + GridColumnGroupingModel, +} from "@mui/x-data-grid"; +import EmptyGroupCell from "@bciers/components/datagrid/cells/EmptyGroupCell"; + +const transferGroupColumns = ( + SearchCell: (params: GridColumnGroupHeaderParams) => JSX.Element, +) => { + const columnGroupModel: GridColumnGroupingModel = [ + { + groupId: "created_at", + headerName: "Submission Date", + renderHeaderGroup: EmptyGroupCell, + children: [{ field: "created_at" }], + }, + { + groupId: "operation__name", + headerName: "Operation", + renderHeaderGroup: SearchCell, + children: [{ field: "operation__name" }], + }, + { + groupId: "facilities__name", + headerName: "Facility", + renderHeaderGroup: SearchCell, + children: [{ field: "facilities__name" }], + }, + { + groupId: "status", + headerName: "Status", + renderHeaderGroup: SearchCell, + children: [{ field: "status" }], + }, + { + groupId: "effective_date", + headerName: "Effective Date", + renderHeaderGroup: EmptyGroupCell, + children: [{ field: "effective_date" }], + }, + { + groupId: "action", + headerName: "Actions", + renderHeaderGroup: EmptyGroupCell, + children: [{ field: "action" }], + }, + ]; + + return columnGroupModel; +}; + +export default transferGroupColumns; diff --git a/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx b/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx new file mode 100644 index 0000000000..95879d78cb --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useMemo, useState } from "react"; +import DataGrid from "@bciers/components/datagrid/DataGrid"; +import { TransferRow } from "./types"; +import HeaderSearchCell from "@bciers/components/datagrid/cells/HeaderSearchCell"; +import ActionCellFactory from "@bciers/components/datagrid/cells/ActionCellFactory"; +import fetchTransfersPageData from "./fetchTransferEventsPageData"; +import { GridRenderCellParams } from "@mui/x-data-grid"; +import transferColumns from "@/registration/app/components/datagrid/models/transfers/transferColumns"; +import transferGroupColumns from "@/registration/app/components/datagrid/models/transfers/transferGroupColumns"; + +const TransfersActionCell = ActionCellFactory({ + generateHref: (params: GridRenderCellParams) => { + return `/transfers/${params.row.id}`; + }, + cellText: "View Details", +}); + +const TransfersDataGrid = ({ + initialData, +}: { + initialData: { + rows: TransferRow[]; + row_count: number; + }; +}) => { + const [lastFocusedField, setLastFocusedField] = useState(null); + + const SearchCell = useMemo( + () => HeaderSearchCell({ lastFocusedField, setLastFocusedField }), + [lastFocusedField, setLastFocusedField], + ); + + const ActionCell = useMemo(() => TransfersActionCell, []); + + const columns = useMemo(() => transferColumns(ActionCell), [ActionCell]); + + const columnGroup = useMemo( + () => transferGroupColumns(SearchCell), + [SearchCell], + ); + + return ( + + ); +}; + +export default TransfersDataGrid; diff --git a/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx new file mode 100644 index 0000000000..10db935b36 --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx @@ -0,0 +1,29 @@ +import TransferDataGrid from "./TransfersDataGrid"; +import { Suspense } from "react"; +import Loading from "@bciers/components/loading/SkeletonGrid"; +import { TransferRow, TransfersSearchParams } from "./types"; +import fetchTransferEventsPageData from "./fetchTransferEventsPageData"; + +// 🧩 Main component +export default async function TransfersDataGridPage({ + searchParams, +}: { + searchParams: TransfersSearchParams; +}) { + // Fetch transfers data + const transfers: { + rows: TransferRow[]; + row_count: number; + } = await fetchTransferEventsPageData(searchParams); + if (!transfers || "error" in transfers) + throw new Error("Failed to retrieve transfers"); + + // Render the DataGrid component + return ( + }> +
+ +
+
+ ); +} diff --git a/bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.tsx b/bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.tsx new file mode 100644 index 0000000000..ce469e0929 --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.tsx @@ -0,0 +1,47 @@ +import buildQueryParams from "@bciers/utils/src/buildQueryParams"; +import { actionHandler } from "@bciers/actions"; +import { TransfersSearchParams } from "./types"; +import { GridRowsProp } from "@mui/x-data-grid"; +import formatTimestamp from "@bciers/utils/src/formatTimestamp"; + +export const formatTransferRows = (rows: GridRowsProp) => { + return rows.map( + ({ + id, + operation__name, + facilities__name, + status, + effective_date, + created_at, + }) => { + return { + id, + operation__name: operation__name || "N/A", + facilities__name: facilities__name || "N/A", + status, + created_at: formatTimestamp(created_at), + effective_date: formatTimestamp(effective_date), + }; + }, + ); +}; +// 🛠️ Function to fetch transfers +export default async function fetchTransferEventsPageData( + searchParams: TransfersSearchParams, +) { + try { + const queryParams = buildQueryParams(searchParams); + // fetch data from server + const pageData = await actionHandler( + `registration/transfer-events${queryParams}`, + "GET", + "", + ); + return { + rows: formatTransferRows(pageData.items), + row_count: pageData.count, + }; + } catch (error) { + throw error; + } +} diff --git a/bciers/apps/registration/app/components/transfers/types.ts b/bciers/apps/registration/app/components/transfers/types.ts new file mode 100644 index 0000000000..4f91c7784e --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/types.ts @@ -0,0 +1,15 @@ +export interface TransferRow { + id: string; + operation__name?: string; + facilities__name?: string; + status: string; + created_at: string | undefined; + effective_date?: string | undefined; +} + +export interface TransfersSearchParams { + [key: string]: string | number | undefined; + operation?: string; + facilities?: string; + status?: string; +} diff --git a/bciers/apps/registration/app/idir/cas_analyst/transfers/page.tsx b/bciers/apps/registration/app/idir/cas_analyst/transfers/page.tsx new file mode 100644 index 0000000000..10c79cad15 --- /dev/null +++ b/bciers/apps/registration/app/idir/cas_analyst/transfers/page.tsx @@ -0,0 +1,17 @@ +// 🚩 flagging that for shared routes between roles, "Page" code is a component for code maintainability +import Loading from "@bciers/components/loading/SkeletonGrid"; +import TransfersDataGridPage from "@/registration/app/components/transfers/TransfersDataGridPage"; +import { Suspense } from "react"; +import { TransfersSearchParams } from "@/registration/app/components/transfers/types"; + +export default async function Page({ + searchParams, +}: { + searchParams: TransfersSearchParams; +}) { + return ( + }> + + + ); +} diff --git a/bciers/apps/registration/tests/components/transfers/TransfersDataGrid.test.tsx b/bciers/apps/registration/tests/components/transfers/TransfersDataGrid.test.tsx new file mode 100644 index 0000000000..cb8cb0df4c --- /dev/null +++ b/bciers/apps/registration/tests/components/transfers/TransfersDataGrid.test.tsx @@ -0,0 +1,131 @@ +import "@testing-library/jest-dom"; +import { render, screen, within } from "@testing-library/react"; +import { useRouter, useSearchParams } from "@bciers/testConfig/mocks"; +import React from "react"; +import TransfersDataGrid from "@/registration/app/components/transfers/TransfersDataGrid"; + +useRouter.mockReturnValue({ + query: {}, + replace: vi.fn(), +}); + +useSearchParams.mockReturnValue({ + get: vi.fn(), +}); + +const mockResponse = { + rows: [ + { + id: "3b5b95ea-2a1a-450d-8e2e-2e15feed96c9", + operation__name: "Operation 1", + facilities__name: "N/A", + status: "Transferred", + created_at: "Jan 5, 2024\n4:25:37 p.m. PDT", + effective_date: "Feb 1, 2025\n1:00:00 a.m. PST", + }, + { + id: "d99725a7-1c3a-47cb-a59b-e2388ce0fa18", + operation__name: "Operation 2", + facilities__name: "N/A", + status: "To be transferred", + created_at: "Jul 5, 2024\n4:25:37 p.m. PDT", + effective_date: "Aug 21, 2024\n2:00:00 a.m. PDT", + }, + { + id: "f486f2fb-62ed-438d-bb3e-0819b51e3aeb", + operation__name: "N/A", + facilities__name: "Facility 1", + status: "Completed", + created_at: "Jul 5, 2024\n4:25:37 p.m. PDT", + effective_date: "Dec 25, 2024\n1:00:00 a.m. PST", + }, + { + id: "459b80f9-b5f3-48aa-9727-90c30eaf3a58", + operation__name: "N/A", + facilities__name: "Facility 2", + status: "Completed", + created_at: "Jul 5, 2024\n4:25:37 p.m. PDT", + effective_date: "Dec 25, 2024\n1:00:00 a.m. PST", + }, + ], + row_count: 4, +}; + +describe("TransfersDataGrid component", () => { + beforeEach(async () => { + vi.clearAllMocks(); + }); + + it("renders the TransfersDataGrid grid ", async () => { + render(); + + // correct headers + expect( + screen.getByRole("columnheader", { name: "Submission Date" }), + ).toBeVisible(); + expect( + screen.queryByRole("columnheader", { name: "Operation" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Facility" }), + ).toBeVisible(); + expect(screen.getByRole("columnheader", { name: "Status" })).toBeVisible(); + expect( + screen.getByRole("columnheader", { name: "Effective Date" }), + ).toBeVisible(); + + expect(screen.queryAllByPlaceholderText(/Search/i)).toHaveLength(3); + + const rows = screen.getAllByRole("row"); // row 0 is headers and row 1 is filter cells + + const operation1Row = rows[2]; + expect( + within(operation1Row).getByText("Jan 5, 2024 4:25:37 p.m. PDT"), + ).toBeInTheDocument(); + expect(within(operation1Row).getByText("Operation 1")).toBeInTheDocument(); + expect(within(operation1Row).getByText("N/A")).toBeInTheDocument(); + expect(within(operation1Row).getByText("Transferred")).toBeInTheDocument(); + expect( + within(operation1Row).getByText("Feb 1, 2025 1:00:00 a.m. PST"), + ).toBeInTheDocument(); + expect(within(operation1Row).getByText("View Details")).toBeInTheDocument(); + + const opeartion2Row = rows[3]; + expect( + within(opeartion2Row).getByText("Jul 5, 2024 4:25:37 p.m. PDT"), + ).toBeInTheDocument(); + expect(within(opeartion2Row).getByText("Operation 2")).toBeInTheDocument(); + expect(within(opeartion2Row).getByText("N/A")).toBeInTheDocument(); + expect( + within(opeartion2Row).getByText("To be transferred"), + ).toBeInTheDocument(); + expect( + within(opeartion2Row).getByText("Aug 21, 2024 2:00:00 a.m. PDT"), + ).toBeInTheDocument(); + expect(within(opeartion2Row).getByText("View Details")).toBeInTheDocument(); + const facility1Row = rows[4]; + + expect( + within(facility1Row).getByText("Jul 5, 2024 4:25:37 p.m. PDT"), + ).toBeInTheDocument(); + expect(within(facility1Row).getByText("N/A")).toBeInTheDocument(); + expect(within(facility1Row).getByText("Facility 1")).toBeInTheDocument(); + expect(within(facility1Row).getByText("Completed")).toBeInTheDocument(); + expect( + within(facility1Row).getByText("Dec 25, 2024 1:00:00 a.m. PST"), + ).toBeInTheDocument(); + expect(within(facility1Row).getByText("View Details")).toBeInTheDocument(); + + const facility2Row = rows[5]; + expect( + within(facility2Row).getByText("Jul 5, 2024 4:25:37 p.m. PDT"), + ).toBeInTheDocument(); + expect(within(facility2Row).getByText("N/A")).toBeInTheDocument(); + expect(within(facility2Row).getByText("Facility 2")).toBeInTheDocument(); + expect(within(facility2Row).getByText("Completed")).toBeInTheDocument(); + expect( + within(facility2Row).getByText("Dec 25, 2024 1:00:00 a.m. PST"), + ).toBeInTheDocument(); + expect(within(facility2Row).getByText("View Details")).toBeInTheDocument(); + }); +}); diff --git a/bciers/apps/registration/tests/components/transfers/TransfersDataGridPage.test.tsx b/bciers/apps/registration/tests/components/transfers/TransfersDataGridPage.test.tsx new file mode 100644 index 0000000000..997c4e6765 --- /dev/null +++ b/bciers/apps/registration/tests/components/transfers/TransfersDataGridPage.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from "@testing-library/react"; +import { + fetchTransferEventsPageData, + useRouter, + useSearchParams, +} from "@bciers/testConfig/mocks"; +import TransfersDataGridPage from "@/registration/app/components/transfers/TransfersDataGridPage"; + +useRouter.mockReturnValue({ + query: {}, + replace: vi.fn(), +}); + +useSearchParams.mockReturnValue({ + get: vi.fn(), +}); + +vi.mock( + "apps/registration/app/components/transfers/fetchTransferEventsPageData", + () => ({ + default: fetchTransferEventsPageData, + }), +); + +const mockResponse = { + items: [ + { + operation__id: "3b5b95ea-2a1a-450d-8e2e-2e15feed96c9", + operation__name: "Operation 3", + facilities__name: null, + facility__id: null, + id: "3b5b95ea-2a1a-450d-8e2e-2e15feed96c9", + effective_date: "2025-02-01T09:00:00Z", + status: "Transferred", + created_at: "2024-07-05T23:25:37.892Z", + }, + { + operation__id: "d99725a7-1c3a-47cb-a59b-e2388ce0fa18", + operation__name: "Operation 6", + facilities__name: null, + facility__id: null, + id: "d99725a7-1c3a-47cb-a59b-e2388ce0fa18", + effective_date: "2024-08-21T09:00:00Z", + status: "To be transferred", + created_at: "2024-07-05T23:25:37.892Z", + }, + { + operation__id: null, + operation__name: null, + facilities__name: "Facility 1", + facility__id: "f486f2fb-62ed-438d-bb3e-0819b51e3aeb", + id: "f486f2fb-62ed-438d-bb3e-0819b51e3aeb", + effective_date: "2024-12-25T09:00:00Z", + status: "Completed", + created_at: "2024-07-05T23:25:37.892Z", + }, + { + operation__id: null, + operation__name: null, + facilities__name: "Facility 2", + facility__id: "459b80f9-b5f3-48aa-9727-90c30eaf3a58", + id: "459b80f9-b5f3-48aa-9727-90c30eaf3a58", + effective_date: "2024-12-25T09:00:00Z", + status: "Completed", + created_at: "2024-07-05T23:25:37.892Z", + }, + ], + row_count: 4, +}; + +describe("Transfers component", () => { + beforeEach(async () => { + vi.clearAllMocks(); + }); + + it("throws an error when there's a problem fetching data", async () => { + fetchTransferEventsPageData.mockReturnValueOnce(undefined); + await expect(async () => { + render(await TransfersDataGridPage({ searchParams: {} })); + }).rejects.toThrow("Failed to retrieve transfers"); + expect(screen.queryByRole("grid")).not.toBeInTheDocument(); + }); + + it("renders the TransfersDataGrid component when there are transfers in the database", async () => { + fetchTransferEventsPageData.mockReturnValueOnce(mockResponse); + render(await TransfersDataGridPage({ searchParams: {} })); + expect(screen.getByRole("grid")).toBeVisible(); + expect( + screen.queryByText(/No transfers data in database./i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/bciers/libs/testConfig/src/mocks.ts b/bciers/libs/testConfig/src/mocks.ts index e71a39018b..794169476c 100644 --- a/bciers/libs/testConfig/src/mocks.ts +++ b/bciers/libs/testConfig/src/mocks.ts @@ -35,6 +35,7 @@ const useSession = vi.fn(); const auth = vi.fn(); const fetchOperationsPageData = vi.fn(); const fetchOperatorsPageData = vi.fn(); +const fetchTransferEventsPageData = vi.fn(); export { actionHandler, @@ -49,4 +50,5 @@ export { fetchOperationsPageData, fetchOperatorsPageData, notFound, + fetchTransferEventsPageData, }; diff --git a/bciers/libs/utils/src/formatTimestamp.ts b/bciers/libs/utils/src/formatTimestamp.ts new file mode 100644 index 0000000000..6185f14981 --- /dev/null +++ b/bciers/libs/utils/src/formatTimestamp.ts @@ -0,0 +1,24 @@ +const formatTimestamp = (timestamp: string) => { + if (!timestamp) return undefined; + + const date = new Date(timestamp).toLocaleString("en-CA", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "America/Vancouver", + }); + + const timeWithTimeZone = new Date(timestamp).toLocaleString("en-CA", { + hour: "numeric", + minute: "numeric", + second: "numeric", + timeZoneName: "short", + timeZone: "America/Vancouver", + }); + + // Return with a line break so we can display date and time on separate lines + // in the DataGrid cell using whiteSpace: "pre-line" CSS + return `${date}\n${timeWithTimeZone}`; +}; + +export default formatTimestamp;