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