Currently, components could belong to bikes. But let’s say I have 3 chains and I rotate them every month. Or I have multiple wheelsets and I rotate them between multiple bikes. It would be super nice to track when something was mounted and when something is unmounted.
One of the simplest solutions for that would be to create a table to make these connections.
class CreateComponentAssignments < ActiveRecord::Migration[6.0]
def change
create_table :component_assignments do |t|
t.references :bike, foreign_key: true
t.references :component, foreign_key: true
t.datetime :started_at
t.datetime :ended_at
t.timestamps
end
end
end
With new record
require "active_record"
module Db
module Records
class ComponentAssignment < ActiveRecord::Base
belongs_to :bike
belongs_to :component
end
end
end
but also I need to update old ones
module Db
module Records
class Component < ActiveRecord::Base
has_many :component_assignments, class_name: "Db::Records::ComponentAssignment"
has_many :bikes, through: :component_assignments
end
end
end
module Db
module Records
class Bike < ActiveRecord::Base
has_many :component_assignments, class_name: "Db::Records::ComponentAssignment"
has_many :components, through: :component_assignments
end
end
end
With this setup, you can now create and manage associations between components and bikes using the ComponentAssignment model. Each ComponentAssignment record will have a started_at and an optional ended_at to represent the time period for which the component is associated with a bike.
For example, to assign a component to a bike for a specific period, you can do the following:
component = Db::Records::Component.find_by(name: "Chain")
bike = Db::Records::Bike.find_by(name: "Mountain Bike")
assignment = ComponentAssignment.create(component: component, bike: bike, started_at: Time.new(2023, 6, 1, 11, 30), ended_at: Time.new(2023, 6, 30, 12, 00))
Later, if you want to change the association for a component, you can update the ended_at for the existing assignment and create a new assignment for the new association:
assignment = ComponentAssignment.find_by(component: component, bike: bike, ended_at: nil)
assignment.update(ended_at: Time.now)
new_bike = Db::Records::Bike.find_by(name: "Road Bike")
new_assignment = ComponentAssignment.create(component: component, bike: new_bike, started_at: Time.now)
With this approach, keeping bike_id in component record is currently redundant. But in the controller, we have an endpoint for filtering components by bike. Would be nice if it still works. Method all_by(filters) in the repository is universal, and probably be useful in the future, but right now I can replace it with something more useful for this situation.
def all_by_bikes(bike_id:)
assignment_table = Db::Records::ComponentAssignment.arel_table
Db::Records::Component
.joins(:component_assignments)
.where(
Db::Records::ComponentAssignment.arel_table[:bike_id].eq(bike_id)
.and(assignment_table[:started_at].lteq(Time.now))
.and(assignment_table[:ended_at].gteq(Time.now).or(assignment_table[:ended_at].eq(nil)))
)
.map { |record| to_model(record).to_h }
end
What happen in this query? Let’s break down the query line by line:
assignment_table = Db::Records::ComponentAssignment.arel_table
In this line, we create a variable assignment_table, which holds the Arel table object for the Db::Records::ComponentAssignment table. This allows us to reference the table’s columns and build conditions using the Arel syntax.
Db::Records::Component
.joins(:component_assignments)
Here, we are querying the Db::Records::Component table and using the joins method to join it with the component_assignments table. This ensures that we fetch only those components that have related component assignments.
.where(
Db::Records::ComponentAssignment.arel_table[:bike_id].eq(bike_id)
.and(assignment_table[:started_at].lteq(Time.now))
.and(assignment_table[:ended_at].gteq(Time.now).or(assignment_table[:ended_at].eq(nil)))
)
This part specifies the conditions for the query using the where
method. Let’s break it down further:
Db::Records::ComponentAssignment.arel_table[:bike_id].eq(bike_id)
: This condition checks if thebike_id
column in thecomponent_assignments
table is equal to the givenbike_id
. It filters components based on the specifiedbike_id
.assignment_table[:started_at].lteq(Time.now)
: This condition checks if thestarted_at
column in thecomponent_assignments
table is less than or equal to the current time (Time.now
). It ensures that we only consider assignments that have started on or before today.assignment_table[:ended_at].gteq(Time.now).or(assignment_table[:ended_at].eq(nil))
: This condition checks if theended_at
column in thecomponent_assignments
table is greater than or equal to the current time (Time.now
) or if it isNULL
. This allows us to include assignments that have either not ended yet (greater than now) or have no specified end date.
.map { |record| to_model(record).to_h }
Finally, after specifying the conditions, we use the map method to convert the resulting ActiveRecord objects to your custom model objects using the to_model method. The .to_h at the end converts the model objects to a hash.
Overall, this query fetches components that have associated component assignments with a specific bike_id and are currently valid based on their start and end dates (if any). It then converts the result to your custom model objects and returns them as an array of hashes. I remove bike_id from the component earlier. But It would be nice to return bike_id based on component usage. Frontend would know about which bike should get details.
def to_model(record)
Components::Model.new(
id: record.id,
name: record.name,
brand: record.brand,
model: record.model,
weight: record.weight,
notes: record.notes,
bike_id: last_bike_id(record)
)
end
def last_bike_id(record)
record.component_assignments.where(ended_at: nil).last&.bike&.id
end
Rspec tests for that should be much more fun
describe "#all_by_bikes" do
let(:today) { Date.today }
it "returns all components assigned to a specific bike and currently valid" do
bike = Db::Records::Bike.create(name: "Test Bike")
component1 = Db::Records::Component.create(name: "Component 1")
component2 = Db::Records::Component.create(name: "Component 2")
assignment1 = Db::Records::ComponentAssignment.create(bike: bike, component: component1, start_date: today - 2.days, end_date: today + 2.days)
assignment2 = Db::Records::ComponentAssignment.create(bike: bike, component: component2, start_date: today - 5.days, end_date: today - 3.days)
components = repository.all_by_bikes(bike_id: bike.id)
expect(components).to be_an(Array)
expect(components.length).to eq(1)
component = components.first
expect(component).to include(
id: component1.id,
name: component1.name,
)
end
it "returns an empty array when no components are assigned to the bike" do
bike = Db::Records::Bike.create(name: "Test Bike")
components = repository.all_by_bikes(bike_id: bike.id)
expect(components).to be_an(Array)
expect(components).to be_empty
end
it "returns an empty array when no valid assignments exist for the bike" do
bike = Db::Records::Bike.create(name: "Test Bike")
component1 = Db::Records::Component.create(name: "Component 1")
assignment1 = Db::Records::ComponentAssignment.create(bike: bike, component: component1, start_date: today - 5.days, end_date: today - 3.days)
components = repository.all_by_bikes(bike_id: bike.id)
expect(components).to be_an(Array)
expect(components).to be_empty
end
end
Now I have a nice question. Should mount and unmount be on the bike controller or component controller, or maybe it should be separate controllers? Mounting and unmounting are actions that affect components, but also bikes. Bikes are made from components. When you mount a component, you are associating it with a specific bike, but the main entity being modified is the component. Similarly, when you unmount a component, you are removing the association from the component.
Therefore, it would be more appropriate to have separate controllers for Components
and ComponentAssignments
With this design, the actions are more logically organized and aligned with the concepts they represent. The ComponentsController
handles CRUD operations for components, while the ComponentAssignmentsController
handles the specific actions related to mounting and unmounting components. The create
action is responsible for mounting a component, while the destroy
action is responsible for unmounting a component
module ComponentAssignments
class Controller
def create(request)
component_data = ComponentAssignments::Contract.new.call(JSON.parse(request.body.read))
if component_data.errors.to_h.any?
[500, {"content-type" => "text/plain"}, ["Error creating component"]]
else
component = ComponentAssignments::Repository.new.create(
bike_id: component_data["bike_id"],
component_id: component_data["component_id"]
)
if component
[201, {"content-type" => "text/plain"}, ["Create"]]
else
[500, {"content-type" => "text/plain"}, ["Error creating component assignments"]]
end
end
end
def delete(request)
component_data = ComponentAssignments::Contract.new.call(JSON.parse(request.body.read))
if component_data.errors.to_h.any?
[500, {"content-type" => "text/plain"}, ["Error creating component"]]
else
delete = ComponentAssignments::Repository.new.delete(
bike_id: component_data["bike_id"],
component_id: component_data["component_id"]
)
if delete
[200, {"content-type" => "text/plain"}, ["Delete assignment"]]
else
[500, {"content-type" => "text/plain"}, ["Error deleting component assignments"]]
end
end
rescue ComponentAssignments::RecordNotFound
[404, {"content-type" => "text/plain"}, ["Not Found"]]
end
end
end
I aligned my frontend to use this endpoint, but I’m close to the moment when I should rethink and rewrite frontend part of the application.
function toggleComponentAssignmentForm() {
const componentAssignmentForm = document.getElementById('componentAssignmentForm');
if (componentAssignmentForm.style.display === 'none') {
showComponentAssignmentForm();
} else {
hideComponentAssignmentForm();
}
}
// Function to show the component assignment form
function showComponentAssignmentForm() {
Promise.all([fetch('/bikes'), fetch('/components')])
.then(responses => Promise.all(responses.map(response => response.json())))
.then(([bikesData, componentsData]) => {
if (bikesData.length === 0) {
alert('No bikes available. Please create a bike first.');
} else {
const componentAssignmentForm = document.getElementById('componentAssignmentForm');
componentAssignmentForm.style.display = 'block';
const bikeSelect = document.getElementById('bikeSelect');
bikeSelect.innerHTML = '';
bikesData.forEach(bike => {
const option = document.createElement('option');
option.value = bike.id;
option.textContent = bike.name;
bikeSelect.appendChild(option);
});
const componentSelect = document.getElementById('componentSelect');
componentSelect.innerHTML = '';
const availableComponents = componentsData.filter(component => component.bike_id === null);
availableComponents.forEach(component => {
const option = document.createElement('option');
option.value = component.id;
option.textContent = component.name;
componentSelect.appendChild(option);
});
}
})
.catch(error => {
console.error('Error:', error);
bikesList.textContent = 'Error retrieving data.';
});
}
// Function to hide the component assignment form
function hideComponentAssignmentForm() {
const componentAssignmentForm = document.getElementById('componentAssignmentForm');
componentAssignmentForm.style.display = 'none';
}
// Function to create a new component assignment
function createComponentAssignment() {
const bikeSelect = document.getElementById('bikeSelect');
const componentSelect = document.getElementById('componentSelect');
const selectedBikeId = bikeSelect.value;
const selectedComponentId = componentSelect.value;
if (selectedBikeId && selectedComponentId) {
fetch(`/component_assignments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
bike_id: parseInt(selectedBikeId),
component_id: parseInt(selectedComponentId)
})
})
.then(response => {
if (response.ok) {
viewAllComponents();
} else {
throw new Error('Failed to create component assignment.');
}
})
.catch(error => {
console.error('Error:', error);
bikesList.textContent = 'Error creating component assignment.';
});
} else {
alert('Please select a bike and a component before creating the assignment.');
}
}
// Function to delete a component assignment
function deleteComponentAssignment(component_id, bike_id) {
const selectedBikeId = bike_id;
const selectedComponentId = component_id;
if (selectedBikeId && selectedComponentId) {
fetch(`/component_assignments`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
bike_id: parseInt(selectedBikeId),
component_id: parseInt(selectedComponentId)
})
})
.then(response => {
if (response.ok) {
viewAllComponents();
} else {
throw new Error('Failed to delete component assignment.');
}
})
.catch(error => {
console.error('Error:', error);
bikesList.textContent = 'Error delete component assignment.';
});
}
}
By implementing the proposed solution and refining the frontend, you can easily track and manage component assignments for your bike inventory system. The ComponentAssignments table helps you record the mounting and unmounting of components, providing valuable insights into component usage and optimizing your bike maintenance process. The whole code is on github