diff --git a/migrations/app/migrations_manifest.txt b/migrations/app/migrations_manifest.txt index 47d85b00ed1..0c4f0c85126 100644 --- a/migrations/app/migrations_manifest.txt +++ b/migrations/app/migrations_manifest.txt @@ -1077,6 +1077,7 @@ 20250110001339_update_nts_release_enum_name.up.sql 20250110153428_add_shipment_address_updates_to_move_history.up.sql 20250110214012_homesafeconnect_cert.up.sql +20250113152050_rename_ubp.up.sql 20250113201232_update_estimated_pricing_procs_add_is_peak_func.up.sql 20250116200912_disable_homesafe_stg_cert.up.sql 20250120144247_update_pricing_proc_to_use_110_percent_weight.up.sql diff --git a/migrations/app/schema/20250113152050_rename_ubp.up.sql b/migrations/app/schema/20250113152050_rename_ubp.up.sql new file mode 100644 index 00000000000..41a5f532193 --- /dev/null +++ b/migrations/app/schema/20250113152050_rename_ubp.up.sql @@ -0,0 +1 @@ +update re_services set name = 'International UB price' where code = 'UBP'; \ No newline at end of file diff --git a/pkg/gen/ghcapi/embedded_spec.go b/pkg/gen/ghcapi/embedded_spec.go index 21cf1cbea52..685b7bb36c4 100644 --- a/pkg/gen/ghcapi/embedded_spec.go +++ b/pkg/gen/ghcapi/embedded_spec.go @@ -9527,6 +9527,11 @@ func init() { "format": "date", "x-nullable": true }, + "sort": { + "description": "Sort order for service items to be displayed for a given shipment type.", + "type": "string", + "x-nullable": true + }, "standaloneCrate": { "type": "boolean", "x-nullable": true @@ -26664,6 +26669,11 @@ func init() { "format": "date", "x-nullable": true }, + "sort": { + "description": "Sort order for service items to be displayed for a given shipment type.", + "type": "string", + "x-nullable": true + }, "standaloneCrate": { "type": "boolean", "x-nullable": true diff --git a/pkg/gen/ghcmessages/m_t_o_service_item.go b/pkg/gen/ghcmessages/m_t_o_service_item.go index 9f20d0d5a7b..7498cf4674f 100644 --- a/pkg/gen/ghcmessages/m_t_o_service_item.go +++ b/pkg/gen/ghcmessages/m_t_o_service_item.go @@ -161,6 +161,9 @@ type MTOServiceItem struct { // Format: date SitRequestedDelivery *strfmt.Date `json:"sitRequestedDelivery,omitempty"` + // Sort order for service items to be displayed for a given shipment type. + Sort *string `json:"sort,omitempty"` + // standalone crate StandaloneCrate *bool `json:"standaloneCrate,omitempty"` diff --git a/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go b/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go index c05b5c5d10b..fa61758302e 100644 --- a/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go +++ b/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go @@ -1881,6 +1881,15 @@ func MTOServiceItemModel(s *models.MTOServiceItem, storer storage.FileStorer) *g serviceRequestDocs[i] = payload } } + var sort *string + if s.ReService.ReServiceItems != nil { + for _, reServiceItem := range *s.ReService.ReServiceItems { + if s.MTOShipment.MarketCode == reServiceItem.MarketCode && s.MTOShipment.ShipmentType == reServiceItem.ShipmentType { + sort = reServiceItem.Sort + break + } + } + } payload := &ghcmessages.MTOServiceItem{ ID: handlers.FmtUUID(s.ID), MoveTaskOrderID: handlers.FmtUUID(s.MoveTaskOrderID), @@ -1896,6 +1905,7 @@ func MTOServiceItemModel(s *models.MTOServiceItem, storer storage.FileStorer) *g SitDepartureDate: handlers.FmtDateTimePtr(s.SITDepartureDate), SitCustomerContacted: handlers.FmtDatePtr(s.SITCustomerContacted), SitRequestedDelivery: handlers.FmtDatePtr(s.SITRequestedDelivery), + Sort: sort, Status: ghcmessages.MTOServiceItemStatus(s.Status), Description: handlers.FmtStringPtr(s.Description), Dimensions: MTOServiceItemDimensions(s.Dimensions), diff --git a/pkg/handlers/ghcapi/internal/payloads/model_to_payload_test.go b/pkg/handlers/ghcapi/internal/payloads/model_to_payload_test.go index fa57a7ae313..4f2b6622ee2 100644 --- a/pkg/handlers/ghcapi/internal/payloads/model_to_payload_test.go +++ b/pkg/handlers/ghcapi/internal/payloads/model_to_payload_test.go @@ -1221,6 +1221,51 @@ func (suite *PayloadsSuite) TestMTOServiceItemModel() { suite.NotNil(result, "Expected result to not be nil for valid MTOServiceItem") suite.Equal(handlers.FmtString(models.MarketOconus.FullString()), result.Market, "Expected Market to be OCONUS") }) + + suite.Run("sets Sort from correct serviceItem", func() { + reServiceID := uuid.Must(uuid.NewV4()) + + reServiceItems := make(models.ReServiceItems, 3) + mockReService := models.ReService{ + ID: reServiceID, + Code: models.ReServiceCodeUBP, + Name: "Test ReService", + ReServiceItems: &reServiceItems, + } + + mockMTOShipment := models.MTOShipment{ + ShipmentType: models.MTOShipmentTypeUnaccompaniedBaggage, + MarketCode: models.MarketCodeInternational, + } + + reServiceItems[0] = models.ReServiceItem{ + ReService: mockReService, + ShipmentType: models.MTOShipmentTypeHHG, + MarketCode: models.MarketCodeInternational, + Sort: models.StringPointer("0"), + } + reServiceItems[1] = models.ReServiceItem{ + ReService: mockReService, + ShipmentType: models.MTOShipmentTypeUnaccompaniedBaggage, + MarketCode: models.MarketCodeInternational, + Sort: models.StringPointer("1"), + } + reServiceItems[2] = models.ReServiceItem{ + ReService: mockReService, + ShipmentType: models.MTOShipmentTypeUnaccompaniedBaggage, + MarketCode: models.MarketCodeDomestic, + Sort: models.StringPointer("2"), + } + + mockMtoServiceItem := models.MTOServiceItem{ + ReService: mockReService, + MTOShipment: mockMTOShipment, + } + + result := MTOServiceItemModel(&mockMtoServiceItem, suite.storer) + suite.NotNil(result, "Expected result to not be nil for valid MTOServiceItem") + suite.Equal("1", *result.Sort, "Expected to get the Sort value by matching the correct ReServiceItem using ShipmentType and MarketCode.") + }) } func (suite *PayloadsSuite) TestPort() { diff --git a/pkg/handlers/ghcapi/mto_service_items.go b/pkg/handlers/ghcapi/mto_service_items.go index 35b390c3eec..f2e727e8418 100644 --- a/pkg/handlers/ghcapi/mto_service_items.go +++ b/pkg/handlers/ghcapi/mto_service_items.go @@ -350,6 +350,8 @@ func (h ListMTOServiceItemsHandler) Handle(params mtoserviceitemop.ListMTOServic query.NewQueryAssociation("SITDestinationFinalAddress"), query.NewQueryAssociation("SITOriginHHGOriginalAddress"), query.NewQueryAssociation("SITOriginHHGActualAddress"), + query.NewQueryAssociation("ReService.ReServiceItems"), + query.NewQueryAssociation("MTOShipment"), }) var serviceItems models.MTOServiceItems diff --git a/pkg/handlers/ghcapi/mto_service_items_test.go b/pkg/handlers/ghcapi/mto_service_items_test.go index 0d8629aed78..8044580f0b0 100644 --- a/pkg/handlers/ghcapi/mto_service_items_test.go +++ b/pkg/handlers/ghcapi/mto_service_items_test.go @@ -39,33 +39,21 @@ import ( ) func (suite *HandlerSuite) TestListMTOServiceItemHandler() { - reServiceID, _ := uuid.NewV4() - serviceItemID, _ := uuid.NewV4() - mtoShipmentID, _ := uuid.NewV4() - var mtoID uuid.UUID - setupTestData := func() (models.User, models.MTOServiceItems) { + setupTestData := func() (models.User, models.MTOServiceItems, uuid.UUID) { mto := factory.BuildMove(suite.DB(), nil, nil) - mtoID = mto.ID reService := factory.FetchReService(suite.DB(), []factory.Customization{ { Model: models.ReService{ - ID: reServiceID, Code: "TEST10000", }, }, }, nil) - mtoShipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ - { - Model: models.MTOShipment{ID: mtoShipmentID}, - }, - }, nil) + mtoShipment := factory.BuildMTOShipment(suite.DB(), nil, nil) requestUser := factory.BuildUser(nil, nil, nil) serviceItem := factory.BuildMTOServiceItem(suite.DB(), []factory.Customization{ { - Model: models.MTOServiceItem{ - ID: serviceItemID, - }, + Model: models.MTOServiceItem{}, }, { Model: mto, @@ -132,11 +120,70 @@ func (suite *HandlerSuite) TestListMTOServiceItemHandler() { serviceItems := models.MTOServiceItems{serviceItem, originSit, destinationSit} - return requestUser, serviceItems + return requestUser, serviceItems, mto.ID + } + + setupIUBTestData := func() (models.User, models.MTOServiceItems, uuid.UUID) { + mto := factory.BuildMove(suite.DB(), nil, nil) + mtoShipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + ShipmentType: models.MTOShipmentTypeUnaccompaniedBaggage, + MarketCode: models.MarketCodeInternational, + }, + }, + }, nil) + requestUser := factory.BuildUser(nil, nil, nil) + + poeFsc := factory.BuildMTOServiceItem(suite.DB(), []factory.Customization{ + { + Model: models.MTOServiceItem{ + Status: models.MTOServiceItemStatusApproved, + }, + }, + { + Model: mto, + LinkOnly: true, + }, + { + Model: mtoShipment, + LinkOnly: true, + }, + { + Model: models.ReService{ + Code: models.ReServiceCodePOEFSC, + }, + }, + }, nil) + + ubp := factory.BuildMTOServiceItem(suite.DB(), []factory.Customization{ + { + Model: models.MTOServiceItem{ + Status: models.MTOServiceItemStatusApproved, + }, + }, + { + Model: mto, + LinkOnly: true, + }, + { + Model: mtoShipment, + LinkOnly: true, + }, + { + Model: models.ReService{ + Code: models.ReServiceCodeUBP, + }, + }, + }, nil) + + serviceItems := models.MTOServiceItems{poeFsc, ubp} + + return requestUser, serviceItems, mto.ID } suite.Run("Successful list fetch - Integration Test", func() { - requestUser, serviceItems := setupTestData() + requestUser, serviceItems, mtoID := setupTestData() req := httptest.NewRequest("GET", fmt.Sprintf("/move_task_orders/%s/mto_service_items", mtoID.String()), nil) req = suite.AuthenticateUserRequest(req, requestUser) @@ -203,8 +250,54 @@ func (suite *HandlerSuite) TestListMTOServiceItemHandler() { } }) + suite.Run("Successful sorted serviceItems for UB", func() { + requestUser, serviceItems, mtoID := setupIUBTestData() + req := httptest.NewRequest("GET", fmt.Sprintf("/move_task_orders/%s/mto_service_items", mtoID.String()), nil) + req = suite.AuthenticateUserRequest(req, requestUser) + + params := mtoserviceitemop.ListMTOServiceItemsParams{ + HTTPRequest: req, + MoveTaskOrderID: *handlers.FmtUUID(serviceItems[0].MoveTaskOrderID), + } + + queryBuilder := query.NewQueryBuilder() + listFetcher := fetch.NewListFetcher(queryBuilder) + fetcher := fetch.NewFetcher(queryBuilder) + counselingPricer := ghcrateengine.NewCounselingServicesPricer() + moveManagementPricer := ghcrateengine.NewManagementServicesPricer() + handler := ListMTOServiceItemsHandler{ + suite.createS3HandlerConfig(), + listFetcher, + fetcher, + counselingPricer, + moveManagementPricer, + } + + // Validate incoming payload: no body to validate + + response := handler.Handle(params) + suite.IsType(&mtoserviceitemop.ListMTOServiceItemsOK{}, response) + okResponse := response.(*mtoserviceitemop.ListMTOServiceItemsOK) + + // Validate outgoing payload + suite.NoError(okResponse.Payload.Validate(strfmt.Default)) + fmt.Println(okResponse.Payload) + + suite.Len(okResponse.Payload, 2) + // Validate that sort field is populated for service items which have it. + // These test values can be updated to match any DB changes. + for _, payload := range okResponse.Payload { + if payload.ReServiceCode != nil && *payload.ReServiceCode == models.ReServiceCodePOEFSC.String() { + suite.Equal("2", *payload.Sort) + } + if payload.ReServiceCode != nil && *payload.ReServiceCode == models.ReServiceCodeUBP.String() { + suite.Equal("1", *payload.Sort) + } + } + }) + suite.Run("Failure list fetch - Internal Server Error", func() { - requestUser, serviceItems := setupTestData() + requestUser, serviceItems, mtoID := setupTestData() req := httptest.NewRequest("GET", fmt.Sprintf("/move_task_orders/%s/mto_service_items", mtoID.String()), nil) req = suite.AuthenticateUserRequest(req, requestUser) @@ -252,7 +345,7 @@ func (suite *HandlerSuite) TestListMTOServiceItemHandler() { }) suite.Run("Failure list fetch - 404 Not Found - Move Task Order ID", func() { - requestUser, serviceItems := setupTestData() + requestUser, serviceItems, mtoID := setupTestData() req := httptest.NewRequest("GET", fmt.Sprintf("/move_task_orders/%s/mto_service_items", mtoID.String()), nil) req = suite.AuthenticateUserRequest(req, requestUser) diff --git a/pkg/models/re_service.go b/pkg/models/re_service.go index 5fc9d9b3e75..bab371ac5d4 100644 --- a/pkg/models/re_service.go +++ b/pkg/models/re_service.go @@ -119,7 +119,7 @@ const ( ReServiceCodeNSTH ReServiceCode = "NSTH" // ReServiceCodeNSTUB Nonstandard UB ReServiceCodeNSTUB ReServiceCode = "NSTUB" - // ReServiceCodeUBP International UB + // ReServiceCodeUBP International UB price ReServiceCodeUBP ReServiceCode = "UBP" // ReServiceCodeISLH Shipping & Linehaul ReServiceCodeISLH ReServiceCode = "ISLH" @@ -147,6 +147,7 @@ type ReService struct { Priority int `db:"priority" rw:"r"` Name string `json:"name" db:"name" rw:"r"` ServiceLocation *ServiceLocationType `db:"service_location" rw:"r"` + ReServiceItems *ReServiceItems `has_many:"re_service_items" fk_id:"service_id"` CreatedAt time.Time `json:"created_at" db:"created_at" rw:"r"` UpdatedAt time.Time `json:"updated_at" db:"updated_at" rw:"r"` } diff --git a/pkg/services/mto_shipment/shipment_approver_test.go b/pkg/services/mto_shipment/shipment_approver_test.go index 612a2a31120..4e2a45f68ee 100644 --- a/pkg/services/mto_shipment/shipment_approver_test.go +++ b/pkg/services/mto_shipment/shipment_approver_test.go @@ -1055,17 +1055,25 @@ func (suite *MTOShipmentServiceSuite) TestApproveShipment() { err2 := suite.AppContextForTest().DB().EagerPreload("ReService").Where("mto_shipment_id = ?", internationalShipment.ID).Order("created_at asc").All(&serviceItems) suite.NoError(err2) - expectedReserviceCodes := []models.ReServiceCode{ + expectedReServiceCodes := []models.ReServiceCode{ models.ReServiceCodeUBP, + models.ReServiceCodePOEFSC, models.ReServiceCodeIUBPK, models.ReServiceCodeIUBUPK, - models.ReServiceCodePOEFSC, + } + expectedReServiceNames := []string{ + "International UB price", + "International POE Fuel Surcharge", + "International UB pack", + "International UB unpack", } suite.Equal(4, len(serviceItems)) for i := 0; i < len(serviceItems); i++ { actualReServiceCode := serviceItems[i].ReService.Code - suite.True(slices.Contains(expectedReserviceCodes, actualReServiceCode), "Contains unexpected: "+actualReServiceCode.String()) + actualReServiceName := serviceItems[i].ReService.Name + suite.True(slices.Contains(expectedReServiceCodes, actualReServiceCode), "Contains unexpected code: "+actualReServiceCode.String()) + suite.True(slices.Contains(expectedReServiceNames, actualReServiceName), "Contains unexpected name: "+actualReServiceName) } }) @@ -1115,17 +1123,25 @@ func (suite *MTOShipmentServiceSuite) TestApproveShipment() { err2 := suite.AppContextForTest().DB().EagerPreload("ReService").Where("mto_shipment_id = ?", internationalShipment.ID).Order("created_at asc").All(&serviceItems) suite.NoError(err2) - expectedReserviceCodes := []models.ReServiceCode{ + expectedReServiceCodes := []models.ReServiceCode{ models.ReServiceCodeUBP, + models.ReServiceCodePODFSC, models.ReServiceCodeIUBPK, models.ReServiceCodeIUBUPK, - models.ReServiceCodePODFSC, + } + expectedReServiceNames := []string{ + "International UB price", + "International POD Fuel Surcharge", + "International UB pack", + "International UB unpack", } suite.Equal(4, len(serviceItems)) for i := 0; i < len(serviceItems); i++ { actualReServiceCode := serviceItems[i].ReService.Code - suite.True(slices.Contains(expectedReserviceCodes, actualReServiceCode), "Contains unexpected: "+actualReServiceCode.String()) + actualReServiceName := serviceItems[i].ReService.Name + suite.True(slices.Contains(expectedReServiceCodes, actualReServiceCode), "Contains unexpected code: "+actualReServiceCode.String()) + suite.True(slices.Contains(expectedReServiceNames, actualReServiceName), "Contains unexpected name: "+actualReServiceName) } }) @@ -1175,16 +1191,23 @@ func (suite *MTOShipmentServiceSuite) TestApproveShipment() { err2 := suite.AppContextForTest().DB().EagerPreload("ReService").Where("mto_shipment_id = ?", internationalShipment.ID).Order("created_at asc").All(&serviceItems) suite.NoError(err2) - expectedReserviceCodes := []models.ReServiceCode{ + expectedReServiceCodes := []models.ReServiceCode{ models.ReServiceCodeUBP, models.ReServiceCodeIUBPK, models.ReServiceCodeIUBUPK, } + expectedReServiceNames := []string{ + "International UB price", + "International UB pack", + "International UB unpack", + } suite.Equal(3, len(serviceItems)) for i := 0; i < len(serviceItems); i++ { actualReServiceCode := serviceItems[i].ReService.Code - suite.True(slices.Contains(expectedReserviceCodes, actualReServiceCode), "Contains unexpected: "+actualReServiceCode.String()) + actualReServiceName := serviceItems[i].ReService.Name + suite.True(slices.Contains(expectedReServiceCodes, actualReServiceCode), "Contains unexpected code: "+actualReServiceCode.String()) + suite.True(slices.Contains(expectedReServiceNames, actualReServiceName), "Contains unexpected name: "+actualReServiceName) } }) diff --git a/src/components/Office/RequestedServiceItemsTable/RequestedServiceItemsTable.test.jsx b/src/components/Office/RequestedServiceItemsTable/RequestedServiceItemsTable.test.jsx index b18f4ef24ac..3e3a973a64c 100644 --- a/src/components/Office/RequestedServiceItemsTable/RequestedServiceItemsTable.test.jsx +++ b/src/components/Office/RequestedServiceItemsTable/RequestedServiceItemsTable.test.jsx @@ -61,6 +61,30 @@ const serviceItemWithDetails = { }, }; +const serviceItemUBP = { + id: 'ubp123', + createdAt: '2025-01-15', + serviceItem: 'International UB price', + code: 'UBP', + sort: '1', +}; + +const serviceItemIUBPK = { + id: 'iubpk123', + createdAt: '2025-01-15', + serviceItem: 'International UB pack', + code: 'IUBPK', + sort: '3', +}; + +const serviceItemIUBUPK = { + id: 'iubupk123', + createdAt: '2025-01-15', + serviceItem: 'International UB unpack', + code: 'IUBUPK', + sort: '4', +}; + const testDetails = (wrapper) => { const detailTypes = wrapper.find('.detailType'); const detailDefinitions = wrapper.find('.detail dd'); @@ -226,4 +250,39 @@ describe('RequestedServiceItemsTable', () => { expect(approveTextButton.at(1).text().includes('Approve')).toBe(true); expect(approveTextButton.at(2).text().includes('Approve')).toBe(true); }); + + it('displays sorted service items in order', () => { + const serviceItems = [serviceItemIUBPK, serviceItemUBP, serviceItemIUBUPK]; + const wrapper = mount( + + + , + ); + + expect(wrapper.find('.codeName').at(0).text()).toBe('International UB price'); + expect(wrapper.find('.codeName').at(1).text()).toBe('International UB pack'); + expect(wrapper.find('.codeName').at(2).text()).toBe('International UB unpack'); + }); + + it('displays sorted service items in order along with non-sorted service items', () => { + const serviceItems = [serviceItemIUBPK, serviceItemUBP, serviceItemWithCrating, serviceItemIUBUPK]; + const wrapper = mount( + + + , + ); + + expect(wrapper.find('.codeName').at(0).text()).toBe('International UB price'); + expect(wrapper.find('.codeName').at(1).text()).toBe('International UB pack'); + expect(wrapper.find('.codeName').at(2).text()).toBe('International UB unpack'); + expect(wrapper.find('.codeName').at(3).text()).toBe('Domestic crating'); + }); }); diff --git a/src/components/Office/ServiceItemsTable/ServiceItemsTable.jsx b/src/components/Office/ServiceItemsTable/ServiceItemsTable.jsx index 1b8aeb3383c..60f48a93f84 100644 --- a/src/components/Office/ServiceItemsTable/ServiceItemsTable.jsx +++ b/src/components/Office/ServiceItemsTable/ServiceItemsTable.jsx @@ -19,6 +19,7 @@ import { selectDateFieldByStatus, selectDatePrefixByStatus } from 'utils/dates'; import { useGHCGetMoveHistory, useMovePaymentRequestsQueries } from 'hooks/queries'; import ToolTip from 'shared/ToolTip/ToolTip'; import { ShipmentShape } from 'types'; +import { nullSafeStringCompare } from 'utils/string'; // Sorts service items in an order preferred by the customer // Currently only SIT & shorthaul/linehaul receives special sorting @@ -48,12 +49,14 @@ function sortServiceItems(items) { ); // Filter all service items that are not specifically sorted - const remainingServiceItems = items.filter( - (item) => - !haulTypeServiceItemCodes.includes(item.code) && - !destinationServiceItemCodes.includes(item.code) && - !originServiceItemCodes.includes(item.code), - ); + const remainingServiceItems = items + .filter( + (item) => + !haulTypeServiceItemCodes.includes(item.code) && + !destinationServiceItemCodes.includes(item.code) && + !originServiceItemCodes.includes(item.code), + ) + .sort((a, b) => nullSafeStringCompare(a.sort, b.sort)); return [ ...sortedHaulTypeServiceItems, diff --git a/src/components/Office/ShipmentServiceItemsTable/ShipmentServiceItemsTable.test.jsx b/src/components/Office/ShipmentServiceItemsTable/ShipmentServiceItemsTable.test.jsx index 9b18298293b..592ae52467a 100644 --- a/src/components/Office/ShipmentServiceItemsTable/ShipmentServiceItemsTable.test.jsx +++ b/src/components/Office/ShipmentServiceItemsTable/ShipmentServiceItemsTable.test.jsx @@ -25,7 +25,7 @@ const reServiceItemResponse = [ isAutoApproved: true, marketCode: 'i', serviceCode: 'UBP', - serviceName: 'International UB', + serviceName: 'International UB price', shipmentType: 'UNACCOMPANIED_BAGGAGE', }, { @@ -370,7 +370,7 @@ describe('Shipment Service Items Table', () => { describe('renders the intl UB shipment type (CONUS -> OCONUS) with service items', () => { it.each([ - ['International UB'], + ['International UB price'], ['International POE Fuel Surcharge'], ['International UB pack'], ['International UB unpack'], @@ -385,7 +385,7 @@ describe('Shipment Service Items Table', () => { describe('renders the intl UB shipment type (OCONUS -> CONUS) with service items', () => { it.each([ - ['International UB'], + ['International UB price'], ['International POD Fuel Surcharge'], ['International UB pack'], ['International UB unpack'], @@ -399,7 +399,7 @@ describe('Shipment Service Items Table', () => { }); describe('renders the intl UB shipment type (OCONUS -> OCONUS) with service items', () => { - it.each([['International UB'], ['International UB pack'], ['International UB unpack']])( + it.each([['International UB price'], ['International UB pack'], ['International UB unpack']])( 'expects %s to be in the document', async (serviceItem) => { render(); diff --git a/src/utils/string.js b/src/utils/string.js new file mode 100644 index 00000000000..5e4544fd0ff --- /dev/null +++ b/src/utils/string.js @@ -0,0 +1,23 @@ +/* eslint-disable import/prefer-default-export */ +import { isNullUndefinedOrWhitespace } from 'shared/utils'; + +/** + * Compare strings. Null, undefined, and blanks are after other values. + * @returns -1, 0, 1 + */ +export function nullSafeStringCompare(a, b) { + const A_BEFORE = -1; + const A_AFTER = 1; + const SAME = 0; + + if (isNullUndefinedOrWhitespace(a) && isNullUndefinedOrWhitespace(b)) { + return SAME; + } + if (isNullUndefinedOrWhitespace(a)) { + return A_AFTER; + } + if (isNullUndefinedOrWhitespace(b)) { + return A_BEFORE; + } + return a.localeCompare(b); +} diff --git a/src/utils/string.test.js b/src/utils/string.test.js new file mode 100644 index 00000000000..d222f0e4f9b --- /dev/null +++ b/src/utils/string.test.js @@ -0,0 +1,60 @@ +/* eslint-disable import/prefer-default-export */ +import * as stringUtils from 'utils/string'; + +describe('utils string', () => { + describe('nullSafeComparison', () => { + const A_BEFORE = -1; + const A_AFTER = 1; + const SAME = 0; + it('same value', () => { + const res = stringUtils.nullSafeStringCompare('1', '1'); + expect(res).toEqual(SAME); + }); + it('greater than', () => { + const res = stringUtils.nullSafeStringCompare('2', '1'); + expect(res).toEqual(A_AFTER); + }); + it('less than', () => { + const res = stringUtils.nullSafeStringCompare('1', '2'); + expect(res).toEqual(A_BEFORE); + }); + it('both null', () => { + const res = stringUtils.nullSafeStringCompare(null, null); + expect(res).toEqual(SAME); + }); + it('null and value', () => { + const res = stringUtils.nullSafeStringCompare(null, '1'); + expect(res).toEqual(A_AFTER); + }); + it('value and null', () => { + const res = stringUtils.nullSafeStringCompare('1', null); + expect(res).toEqual(A_BEFORE); + }); + it('both undefined', () => { + let udefA; + let udefB; + const res = stringUtils.nullSafeStringCompare(udefA, udefB); + expect(res).toEqual(SAME); + }); + it('undefined and null', () => { + let udefA; + const res = stringUtils.nullSafeStringCompare(udefA, null); + expect(res).toEqual(SAME); + }); + it('null and undefined', () => { + let udefB; + const res = stringUtils.nullSafeStringCompare(null, udefB); + expect(res).toEqual(SAME); + }); + it('undefined and value', () => { + let udefA; + const res = stringUtils.nullSafeStringCompare(udefA, '2'); + expect(res).toEqual(A_AFTER); + }); + it('value and undefined', () => { + let udefB; + const res = stringUtils.nullSafeStringCompare('1', udefB); + expect(res).toEqual(A_BEFORE); + }); + }); +}); diff --git a/swagger-def/definitions/MTOServiceItem.yaml b/swagger-def/definitions/MTOServiceItem.yaml index 9cd6119df81..c71ec5568f6 100644 --- a/swagger-def/definitions/MTOServiceItem.yaml +++ b/swagger-def/definitions/MTOServiceItem.yaml @@ -153,3 +153,7 @@ properties: example: CONUS description: 'To identify whether the service was provided within (CONUS) or (OCONUS)' x-nullable: true + sort: + type: string + description: 'Sort order for service items to be displayed for a given shipment type.' + x-nullable: true diff --git a/swagger/ghc.yaml b/swagger/ghc.yaml index b2f25699908..7a3d6388338 100644 --- a/swagger/ghc.yaml +++ b/swagger/ghc.yaml @@ -8964,6 +8964,12 @@ definitions: To identify whether the service was provided within (CONUS) or (OCONUS) x-nullable: true + sort: + type: string + description: >- + Sort order for service items to be displayed for a given shipment + type. + x-nullable: true MTOServiceItems: description: A list of service items connected to this shipment. type: array