Example: state change form
Here I’ll explore some existing code where a change gets introduced and ultimately how a deeper understanding leads to a new realization and a better implementation.
The main concept in this example is a Shipment. A Shipment is identified by a tracking number and also has a state, like whether it is in transit. This is the type of data you would see when you search for a parcel on the websites of UPS or FedEx.
The example concerns the following piece of code:
module ShipmentViewHelper
STATE_OPTIONS = [
["Created", Shipment::States::CREATED],
["Booked", Shipment::States::BOOKED],
["In transit", Shipment::States::IN_TRANSIT],
["Delivered", Shipment::States::DELIVERED],
["Cancelled", Shipment::States::CANCELLED],
]
end
class StateChangeForm
include ActiveModel::Model
attr_accessor :state, :tracking_number, :comment
def available_state_options
all_options = ShipmentViewHelper::STATE_OPTIONS.dup
all_options.reject! { |(_, option_for_state)| option_for_state == Shipment::States::CREATED } unless state == Shipment::States::CREATED
all_options
end
end
StateChangeForm
is a basic form model that holds various pieces of data related to a Shipment which is used to update the state of the shipment.
The interesting part is the method StateChangeForm#available_state_options
. That method is used to list the available state values that the shipment can change to. The CREATED
state is treated differently because it should not be able to go back to that state once it leaves it.
Change
A new state is introduced: CONFIRMED
. It is similar to the CREATED
state in that it should not be possible to go back to the CONFIRMED
state once it has left that state. A quick way to accomplish this:
module ShipmentViewHelper
STATE_OPTIONS = [
["Created", Shipment::States::CREATED],
["Confirmed", Shipment::States::CONFIRMED],
["Booked", Shipment::States::BOOKED],
["In transit", Shipment::States::IN_TRANSIT],
["Delivered", Shipment::States::DELIVERED],
["Cancelled", Shipment::States::CANCELLED],
]
end
class StateChangeForm
include ActiveModel::Model
attr_accessor :state, :tracking_number, :comment
def available_state_options
all_options = ShipmentViewHelper::STATE_OPTIONS.dup
all_options.reject! { |(_, option_for_state)| option_for_state == Shipment::States::CREATED } unless state == Shipment::States::CREATED
all_options.reject! { |(_, option_for_state)| option_for_state == Shipment::States::CONFIRMED } unless [Shipment::States::CREATED, Shipment::States::CONFIRMED].include?(state)
all_options
end
end
Understand and refactor
Let’s go a bit deeper and try to understand what the underlying idea is for StateChangeForm#available_state_options
:
- if in state
CREATED
, the shipment can change to any state - if in state
CONFIRMED
, the shipment can change to any state except forCREATED
- for the other states, the shipment can change to any state except for
CREATED
andCONFIRMED
This hints at the fact that depending on the state, we want to filter out (aka reject) some states. This simplifies the implementation a lot:
module ShipmentViewHelper
STATE_OPTIONS = [
# ... same as the previous step
]
end
class StateChangeForm
include ActiveModel::Model
attr_accessor :state, :tracking_number, :comment
EXCLUDED_STATES_MAPPING = {
Shipment::States::CREATED => [],
Shipment::States::CONFIRMED => [Shipment::States::CREATED],
}
EXCLUDED_STATES_MAPPING.default = [Shipment::States::CREATED, Shipment::States::CONFIRMED]
def available_state_options
ShipmentViewHelper::STATE_OPTIONS
.reject { |(_, option_for_state)| excluded_states.include?(option_for_state) }
end
def excluded_states
EXCLUDED_STATES_MAPPING.fetch(state)
end
end
This could be further optimized to basically a hash lookup but that’s beside the point.