Practical Redux, Part 11: Nested Data and Trees
This is a post in the Practical Redux series.
Intro 🔗︎
Last time in Part 10, we built modal dialog and context menu systems that were driven by Redux. This time, we're going to add creation of new entities, implement loading of nested relational data, and display that nested data in a tree component.
The code for this project is on Github at github.com/markerikson/project-minimek. The original WIP commits I made for this post can be seen in PR #14: Practical Redux Part 11 WIP, and the final "clean" commits can be seen in in PR #15: Practical Redux Part 11 Final.
I'll be linking to each "final" commit as I go through the post, as well as specific files in those commits. I won't paste every changed file in here or show every single changed line, to save space, but rather try to show the most relevant changes for each commit as appropriate.
Table of Contents 🔗︎
- Library Updates
- Adding New Pilots
- Reorganizing the Unit Info Display
- Loading Nested Unit Data
- Displaying Unit Data as a Tree
- Final Thoughts
- Further Information
Library Updates 🔗︎
It's been a few months since the last post, and both React and Semantic-UI-React have been updated. At the time of this writing, the latest versions are React 16.2 and Semantic-UI-React 0.77.
Right now, this project is on React 15.6. Since our code currently compiles and runs with no warnings, we should be able to safely upgrade to React 16 just by updating our package versions. The main concern would be making sure that our other libraries are also compatible with React 16. Since we previously updated libraries that depended on PropTypes, that's not much of an issue here. The only other issue is our use of Portals for context menus. There's a newer version of the react-portal
library that supports both React 15 and React 16's differing Portal APIs, so we'll update that as well.
yarn upgrade react@16.2 react-dom@16.2 react-portal@4.1.2
There's one small code tweak we need to make, which is changing Portal
to be a named import instead of a default import.
Commit 11b7ef4: Update React-Portal usage to match version 4.x
We'll also upgrade Semantic-UI-React.
yarn upgrade semantic-ui-react@0.77
As a side note, I actually experienced some issues with Yarn's offline mirror feature doing these upgrades. When I upgraded React, the older package tarballs were correctly removed from the ./offline-mirror
feature, but the new tarballs weren't added. Even more oddly, the SUI-React tarball showed up correctly. I eventually resolved this by running yarn cache clean
, then re-attempting the package updates.
Adding New Pilots 🔗︎
So far, we have focused on interacting with the list of pilots. Project Mini-Mek currently has the ability to show a list of pilots, update pilot entries, and delete pilots. However, we're still missing a key capability: actually creating new pilot entries (the 'C' in "CRUD").
We'll tackle that shortly, but first we'll do a bit of code cleanup related to the pilots feature.
Pilots Code Cleanup 🔗︎
The <PilotDetails>
form has some inputs that are in separate rows, but each only takes up a part of the row, leaving some empty space. We can consolidate those into a couple combined rows.
Commit 51377a7: Rearrange PilotDetails form to be more compact
features/pilots/PilotDetails/PilotDetails.jsx
+ <Form.Group>
<Form.Field
name="rank"
label="Rank"
- width={16}
+ width={10}
</Form.Field
// omit other input values and fields
+ </Form.Group>
+ <Form.Group widths="equal">
<Form.Field
name="gunnery"
label="Gunnery"
</Form.Field>
+ </Form.Group>
This looks a bit nicer:
Also, the current list of pilot ranks is hardcoded in the format that SUI-React's Dropdown
wants. We can turn that into a simple constants array, and then derive the display data as needed.
features/pilots/pilotsConstants.js
export const PILOT_EDIT_STOP = "PILOT_EDIT_STOP";
+export const PILOT_RANKS = [
+ "Private",
+ "Corporal",
+ "Sergeant",
+ "Lieutenant",
+ "Captain",
+ "Major",
+ "Colonel",
+];
features/pilots/PilotDetails/PilotDetails.jsx
import {selectCurrentPilot, selectIsEditingPilot} from "../pilotsSelectors";
+import {PILOT_RANKS} from "../pilotsConstants";
-const RANKS = [
- {value: "Private", text : "Private"},
- {value: "Corporal", text : "Corporal"},
- {value: "Sergeant", text : "Sergeant"},
- {value: "Lieutenant", text : "Lieutenant"},
- {value: "Captain", text : "Captain"},
- {value: "Major", text : "Major"},
- {value: "Colonel", text : "Colonel"},
-];
+const RANKS = PILOT_RANKS.map(rank => ({value : rank, text : rank}));
Creating New Entities 🔗︎
We currently have the ability to edit items, and our editing feature has generic actions and reducers for updating the contents of Redux-ORM entities with new values. However, our editing feature is built around the assumption that there are existing items in the state.entities
"current values" slice, and that editing an item should copy that item from state.entities
to the state.editingEntities
"work-in-progress" slice. That assumption no longer holds true for adding and editing new items - we can't copy them because they don't yet exist in the entities
slice.
We need some additional support for creating new entities directly into the editingEntities
slice so that our edit actions can work on them. In addition, our "save edits" logic also assumes that an item with that type and ID already exists in entities
, and tries to look up that Model
instance to apply the updates from the edited model. We need to update the save logic to handle this case as well.
features/editing/editingReducer.js
import {
EDIT_ITEM_EXISTING,
+ EDIT_ITEM_NEW,
EDIT_ITEM_UPDATE,
EDIT_ITEM_APPLY,
EDIT_ITEM_STOP,
EDIT_ITEM_RESET,
} from "./editingConstants";
export function updateEditedEntity(sourceEntities, destinationEntities, payload) {
// Start by reading our "work-in-progress" data
const readSession = orm.session(sourceEntities);
const {itemType, itemID} = payload;
// Look up the model instance for the requested item
const model = getModelByType(readSession, itemType, itemID);
// We of course will be updating our "current" relational data
let writeSession = orm.session(destinationEntities);
const ModelClass = writeSession[itemType];
if(ModelClass.hasId(itemID)) {
// Look up the original Model instance for the top item
const existingItem = ModelClass.withId(itemID);
if(existingItem.updateFrom) {
// Each model class should know how to properly update itself and its
// relations from another model of the same type. Ask the original model to
// update itself based on the "work-in-progress" model. Redux-ORM will apply
// those changes as we go, and update `session.state` immutably.
existingItem.updateFrom(model);
}
}
+ else {
+ const itemContents = model.toJSON();
+ ModelClass.parse(itemContents);
+ }
// Return the updated "current" relational data.
return writeSession.state;
}
+export function editItemNew(state, payload) {
+ const editingEntities = selectEditingEntities(state);
+
+ const updatedEditingEntities = createEntity(editingEntities, payload);
+ return updateEditingEntitiesState(state, updatedEditingEntities);
+}
const editingFeatureReducer = createReducer({}, {
[EDIT_ITEM_EXISTING] : editItemExisting,
+ [EDIT_ITEM_NEW] : editItemNew,
[EDIT_ITEM_UPDATE] : editItemUpdate,
[EDIT_ITEM_APPLY] : editItemApply,
[EDIT_ITEM_STOP] : editItemStop,
[EDIT_ITEM_RESET] : editItemReset,
});
export default editingFeatureReducer;
The EDIT_ITEM_NEW
action payload will contain {itemType, itemID, newItemAttributes}
. Because we've been consistent about naming those fields in our other action types, the case reducer can reuse our existing helper methods for basic entity CRUD, which makes this easy to implement.
Also, we already have the ability to parse in plain JSON representations of our model classes, and are using that as the transfer mechanism to copy values from entities
to editedEntities
. We can easily add that logic in here so that saving new items creates them in entities
.
Generating New Pilot Entries 🔗︎
The next step is to actually generate a new Pilot entry and dispatch EDIT_ITEM_NEW
with the plain JSON representation of that pilot entry. This brings up several things we need to think about.
First, we need to have unique IDs for each Pilot entry. Thus far we've simply used hardcoded integers in our sample data, but we're going to need to generate IDs ourselves here for a couple reasons. Redux-ORM does have support for auto-incrementing numerical IDs based on the max ID value in a "table", but that won't work with our editing approach - the editingEntities.Pilots
table would have no existing items, so it would likely give us a 0 or a 1 as the ID every time. We could calculate the numerical ID based on the contents of state.entities
, but really, relying on the existing entries is not a great approach. It's better if we actually generate unique IDs.
However, this also has some problems. Generating unique IDs involves use of random numbers. While there's nothing preventing you from generating random numbers in a Redux reducer, doing so makes that reducer "impure", and it will return inconsistent output. A Redux reducer should never contain randomness - it should always be handled outside the reducer.
For our purposes, we can handle this by generating new pilot IDs in our action creator. If we needed to actually generate random numbers, there's a couple approaches you can use. I won't cover those here, but see the posts Roll the Dice: Random Numbers in Redux and Random in Redux for solutions and examples.
Another big question is where the logic for default model attribute values should live, and how to override those defaults when a new model instance is created. I've chosen to define a plain object of attributes in the same file as the model class, add a static generate()
function to the model class, and merge together the default attributes and any attributes provided by the caller.
Finally, we're running into an issue with an optimization we applied earlier. We have a memoized getEntitiesSession
selector that ensures we only create a single Redux-ORM Session
instance for each update to the state.entities
slice, just so we aren't creating separate Session
instances every time a connected component's mapState
function re-runs. If we were to use that selector and get that "shared" Session
instance, then use that to generate a new Pilot
instance, the shared Session
would swap out its session.state
field with the updated data. That could potentially cause problems, so we really want to avoid reusing that Session
in this process. My solution is to create a getUnsharedEntitiesSession
selector that just creates a new Session
, which can then be safely used for manipulating data instead of just reading values.
features/entities/entitySelectors.js
+export const getUnsharedEntitiesSession = (state) => {
+ const entities = selectEntities(state);
+ return orm.session(entities);
+}
+const defaultAttributes = {
+ name : "New Pilot",
+ rank : "Private",
+ gunnery : 4,
+ piloting : 5,
+ age : 25,
+};
export default class Pilot extends Model {
+ static generate(newAttributes = {}) {
+ const combinedAttributes = {
+ ...defaultAttributes,
+ ...newAttributes,
+ };
+
+ return this.create(combinedAttributes);
+ }
}
features/pilots/pilotsActions.js
+import cuid from "cuid";
import {
editExistingItem,
+ editNewItem,
applyItemEdits,
stopEditingItem
} from "features/editing/editingActions";
import {selectCurrentPilot, selectIsEditingPilot} from "./pilotsSelectors";
+import {getUnsharedEntitiesSession} from "features/entities/entitySelectors";
+export function addNewPilot() {
+ return (dispatch, getState) => {
+ const session = getUnsharedEntitiesSession(getState());
+ const {Pilot} = session;
+
+ const id = cuid();
+
+ const newPilot = Pilot.generate({id});
+
+ const pilotContents = newPilot.toJSON();
+
+ dispatch(editNewItem("Pilot", id, pilotContents));
+ dispatch(selectPilot(id));
+ dispatch({type : PILOT_EDIT_START});
+ }
=}
Way back at the start of the series, we added the cuid
module for generating IDs. We can finally make use of that here.
Our addNewPilot()
thunk first creates a Session
instance we can use for modifying data. We generate a new ID value, then pass the ID as a field to Pilot.generate()
, which returns a Pilot
instance. We can serialize that to a plain JS object, and dispatch the actions to edit the item, mark it as selected, and update our state to reflect that we're currently editing a pilot.
Pilot Form Updates 🔗︎
With the logic in place, we need to actually add some UI to call addNewPilot()
. We'll add a new section below the <PilotDetails>
form with a button that lets us add and start editing a new pilot.
features/pilots/PilotDetails/PilotCommands.jsx
import React from "react";
import {connect} from "react-redux";
import {Button} from "semantic-ui-react";
import {selectIsEditingPilot} from "../pilotsSelectors";
import {addNewPilot} from "../pilotsActions";
const mapState = (state) => {
const isEditingPilot = selectIsEditingPilot(state);
return {isEditingPilot};
}
const buttonWidth = 140;
const actions = {addNewPilot};
const PilotCommands = (props) => (
<Button
primary
disabled={props.isEditingPilot}
type="button"
onClick={props.addNewPilot}
style={{width : buttonWidth, marginRight : 10}}
>
Add New Pilot
</Button>
);
export default connect(mapState, actions)(PilotCommands);
features/pilots/Pilots/Pilots.jsx
import PilotsList from "../PilotsList";
import PilotDetails from "../PilotDetails";
+import PilotCommands from "../PilotDetails/PilotCommands";
export default class Pilots extends Component {
render() {
// skip other rendering code
<Segment >
<PilotDetails />
</Segment>
+ <Segment>
+ <PilotCommands />
+ </Segment>
</Grid.Column>
</Grid>
</Segment>
}
And now we can hit "Add New Pilot", edit the details, save the edits, and see the pilot added to the list:
Fixing Pilot Selection Logic 🔗︎
There's one issue left from the current code. If we click "Add New Pilot" and then click "Cancel Edits", the "Start Editing" button is still enabled. This is because we still have state.pilots.selectedPilot
set to the ID of the generated pilot and that isn't getting cleared out when we hit Cancel, so it thinks a valid pilot is still selected. We really need to clear the selection if we're canceling the edit.
There's probably a few ways we could handle this. For now, we'll implement the behavior on the action creation side, by checking to see if it's a new pilot when we stop editing.
In addition, the logic for the stopEditingPilot()
and cancelEditingPilot()
thunks is looking pretty similar. We can consolidate that logic into a single function with a flag that indicates whether we should apply the edits or not.
features/pilots/pilotsActions.js
import {selectCurrentPilot, selectIsEditingPilot} from "./pilotsSelectors";
-import {getUnsharedEntitiesSession} from "features/entities/entitySelectors";
import {getEntitiesSession, getUnsharedEntitiesSession} from "features/entities/entitySelectors";
+export function handleStopEditingPilot(applyEdits = true) {
+ return (dispatch, getState) => {
+ const currentPilot = selectCurrentPilot(getState());
+
+ // Determine if it's a new pilot based on the "current" slice contents
+ const session = getEntitiesSession(getState());
+ const {Pilot} = session;
+
+ const isNewPilot = !Pilot.hasId(currentPilot);
+
+ dispatch({type : PILOT_EDIT_STOP});
+
+ if(applyEdits) {
+ dispatch(applyItemEdits("Pilot", currentPilot));
+ }
+
+ dispatch(stopEditingItem("Pilot", currentPilot));
+
+ if(isNewPilot) {
+ dispatch({type : PILOT_SELECT, payload : {currentPilot : null}});
+ }
+ }
+}
export function stopEditingPilot() {
return (dispatch, getState) => {
- const currentPilot = selectCurrentPilot(getState());
-
- dispatch({type : PILOT_EDIT_STOP});
- dispatch(applyItemEdits("Pilot", currentPilot));
- dispatch(stopEditingItem("Pilot", currentPilot));
+ dispatch(handleStopEditingPilot(true));
}
}
export function cancelEditingPilot() {
return (dispatch, getState) => {
- const currentPilot = selectCurrentPilot(getState());
-
- dispatch({type : PILOT_EDIT_STOP});
- dispatch(stopEditingItem("Pilot", currentPilot));
+ dispatch(handleStopEditingPilot(false));
}
}
We can determine if it's a new pilot by seeing whether the currentPilot
ID value actually exists in the "current" Pilot
table. From there, we clear out the "editing pilot" flag, save the edits if appropriate, clear out the entry from the "editing" slice, and clear the selection if necessary.
Reorganizing the Unit Info Display 🔗︎
Most of our work so far has focused on the "Pilots" tab, although we've worked some on the "Unit Info" and "Mechs" tabs. Meanwhile, the "Unit Organization" tab has been left alone since we put together the initial layout. In preparation for our next major chunk of feature work, we're going to consolidate things by moving the tree from the "Unit Organization" tab into our main "Unit Info" tab.
We'll start by extracting a separate <UnitInfoForm>
component from the existing <UnitInfo>
panel.
features/unitInfo/UnitInfo/UnitInfo.jsx
import React, {Component} from "react";
import {
Segment
} from "semantic-ui-react";
import UnitInfoForm from "./UnitInfoForm";
class UnitInfo extends Component {
render() {
return (
<Segment attached="bottom">
<UnitInfoForm />
</Segment>
);
}
}
export default UnitInfo;
Code-wise, this was really more like renaming UnitInfo.jsx
to UnitInfoForm.jsx
, removing a couple of components from its render method, and then creating a new UnitInfo.jsx
file to replace it.
Commit 0ec6f08: Move UnitOrganization under UnitInfo and remove unused tab
import UnitInfo from "features/unitInfo/UnitInfo";
import Pilots from "features/pilots/Pilots";
import Mechs from "features/mechs/Mechs";
-import UnitOrganization from "features/unitOrganization/UnitOrganization";
import Tools from "features/tools/Tools";
import ModalManager from "features/modals/ModalManager";
class App extends Component {
render() {
const tabs = [
{name : "unitInfo", label : "Unit Info", component : UnitInfo,},
{name : "pilots", label : "Pilots", component : Pilots,},
{name : "mechs", label : "Mechs", component : Mechs,},
- {name : "unitOrganization", label : "Unit Organization", component : UnitOrganization},
{name : "tools", label : "Tools", component : Tools},
];
Next, we remove the <UnitOrganization>
component so it's no longer rendered as a separate tab, and move its file inside features/unitInfo
.
Then, we can show <UnitOrganization>
inside of the revamped <UnitInfo>
panel:
Commit d18161b: Add UnitOrganization component to UnitInfo panel
import React, {Component} from "react";
import {
Segment,
Grid,
Header,
} from "semantic-ui-react";
import UnitOrganization from "../UnitOrganization";
import UnitInfoForm from "./UnitInfoForm";
class UnitInfo extends Component {
render() {
return (
<Segment>
<Grid>
<Grid.Column width={10}>
<Header as="h3">Unit Table of Organization</Header>
<Segment>
<UnitOrganization />
</Segment>
</Grid.Column>
<Grid.Column width={6}>
<Header as="h3">Edit Unit</Header>
<Segment>
<UnitInfoForm />
</Segment>
</Grid.Column>
</Grid>
</Segment>
);
}
}
export default UnitInfo;
We'll consolidate the <UnitInfoForm>
"Affiliation" and "Color" fields into one row, similar to what we did with the <PilotDetailsForm>
earlier.
And finally, we'll rename <UnitOrganization>
to <UnitOrganizationTree>
.
Commit 361a1aa: Rename UnitOrganization to UnitOrganizationTree
Note that since we've started this project, <UnitInfo>
has gone from being unconnected, to connected, and is now unconnected again. This is one of the reasons why I dislike specifically splitting code into containers
and components
folders, or having separate SomeComponent
and SomeComponentContainer
files - it's very possible that a component's usage could change over time. Now, I will say that there's a difference between "app-specific" components and truly generic components. We do have a common/components
folder in this project, and it's very reasonable to put completely generic components in there. But, for anything that's actually related to app concepts, I'd rather put it in an appropriate feature folder and just keep it there, regardless of whether it's connected or not. (I have a saved Reactiflux chat log where I discuss my thoughts on Redux "container" components and structuring.)
Loading Nested Unit Data 🔗︎
Our data schema so far has been pretty simple. The sampleData.js
file currently looks like this:
const sampleData = {
unit : {
name : "Black Widow Company",
affiliation : "wd",
color : "black"
},
pilots : [
{
id : 1,
name : "Natasha Kerensky",
rank : "Captain",
gunnery : 2,
piloting : 2,
age : 52,
mech : 1,
},
],
designs : [
{
id : "STG-3R",
name : "Stinger",
weight : 20,
},
],
mechs : [
{
id : 1,
type : "WHM-6R",
pilot : 1,
},
]
}
We've got flat arrays for pilots, designs, and mechs, and we only have a single unit defined. The arrays themselves already reference each other by foreign key IDs, so there's not much processing needed.
It's time to start adding some additional depth to this schema. As mentioned in Part 0 and Part 3, in the Battletech game universe mechs are normally organized into "Lances" of 4 mechs, and "Companies" of 3 lances. We're going to apply that organizational pattern to our current sample data.
Long-term, it would make sense to support loading multiple units, and possibly having the pilots and mechs defined in a nested data tree that matches the organizational structure. We're not going to go quite that far yet. For now, we're going to start treating the unit as another model type in our database, and store the pilots
and mechs
arrays inside of the unit definition. We'll also add a lances
array that stores which pilots belong to which lances. The designs
array will stay outside the unit
field in the data, because mech designs are universal across factions and not specific to a unit.
This also means that we're going to need to make our "parsing" logic a bit more complex, because it will need to handle the now-nested data correctly.
Parsing Unit Entries 🔗︎
The first step is to create our Unit
model.
import {Model, many, attr} from "redux-orm";
export default class Unit extends Model {
static modelName = "Unit";
static fields = {
id : attr(),
name : attr(),
affiliation : attr(),
color : attr(),
pilots : many("Pilot"),
mechs : many("Mech")
};
static parse(unitData) {
const {Pilot, Mech} = this.session;
const parsedData = {
...unitData,
pilots : unitData.pilots.map(pilotEntry => Pilot.parse(pilotEntry)),
mechs : unitData.mechs.map(mechEntry => Mech.parse(mechEntry)),
};
return this.upsert(parsedData);
}
}
There's a few things to note in this file.
First, for other model classes so far, I've tended to write the fields
definition as static get fields() {}
, and usually defined the modelName
field separately, like Pilot.modelName = "Pilot";
. This is somewhat for historical reasons - I first began using Redux-ORM before I had the Class Properties syntax available in my project, so I used getters and plain assignments instead. In theory, all three of these should be equivalent:
// 1) Field declarations added to the class later
class Pilot extends Model {}
Pilot.fields = {
id : attr()
};
// 2) Static getters
class Pilot extends Model {
static get fields() {
return {
id : attr()
};
}
}
// 3) The Stage 3 Class Properties syntax
class Pilot extends Model {
static fields = {
id : attr()
};
}
Going forward, I'll stick with the Class Properties syntax, and define fields
and modelName
inside the class body.
Next, for the first time we're using Redux-ORM's many()
relation. This will set up "through tables" that map together the related IDs from both tables. In this case, we'll have auto-generated model types called UnitPilots
and UnitMechs
, and a sample UnitPilots
entry might look like {id : 2, fromUnitId : 1, toPilotId : 3}
. If we have an instance of a Unit
, the unitModel.pilots
field will be a Redux-ORM QuerySet
that can be turned into an array of Pilot
models or plain JS objects.
Finally, notice that the Unit.parse()
method is more complicated than the others we've seen thus far. Since our other classes haven't had to deal with any nesting, our other parse()
methods have just looked like return this.create(data)
or return this.upsert(data)
. Now, we need to continue recursing down through the nested data to parse any Pilot
or Mech
entries.
Our data loading reducer already runs pilots.map(pilotEntry => Pilot.parse(pilotEntry))
, and the same for mechs. We can do that here instead, but there's a bit of a trick involved. Redux-ORM creates custom subclasses of your model classes every time you instantiate a Session
. Those subclasses are attached to the Session
instance, and any operations involving those subclasses are applied to that specific Session
. So, we can't just do import Pilot from "features/pilots/Pilot"
here - we need to get a reference to the specific Pilot
subclass on the current Session
instance related to where Unit.parse()
is being called.
Fortunately, the actual solution is pretty easy. When this code runs, this
inside the static method will refer to the Session
-specific subclass of Unit
, and Redux-ORM makes the Session
available as this.session
. So, in the same way that we did const {Pilot} = session
over in the reducer, we can do const {Pilot} = this.session
here inside the static class method.
From there, we call Pilot.parse()
and Mech.parse()
as we map over the arrays. Those create the proper entries inside the Session
, and then passing those newly-created model entries into this.upsert()
instead of the original plain objects will tell Redux-ORM to set up the associations between the Unit
and those related models.
With that done, we can restructure the sample data to match:
Commit 58b9115: Restructure sample data to include pilots/mechs inside unit definition
And then we need to update the data loading reducer to call Unit.parse()
instead of parsing the individual pilots and mechs directly:
Commit c8cabf2: Update data loading reducer to parse nested unit definition
app/reducers/entitiesReducer.js
export function loadData(state, payload) {
// Create a Redux-ORM session from our entities "tables"
const session = orm.session(state);
// Get a reference to the correct version of model classes for this Session
- const {Pilot, MechDesign, Mech} = session;
+ const {Unit, Pilot, Mech, MechDesign} = session;
- const {pilots, designs, mechs} = payload;
+ const {unit, designs} = payload;
// Clear out any existing models from state so that we can avoid
// conflicts from the new data coming in if data is reloaded
- [Pilot, Mech, MechDesign].forEach(modelType => {
+ [Unit, Pilot, Mech, MechDesign].forEach(modelType => {
modelType.all().toModelArray().forEach(model => model.delete());
});
// Immutably update the session state as we insert items
+ Unit.parse(unit);
- pilots.forEach(pilot => Pilot.parse(pilot));
designs.forEach(design => MechDesign.parse(design));
- mechs.forEach(mech => Mech.parse(mech));
// Return the new "tables" object containing the updates
return session.state;
}
Extracting Factions 🔗︎
The list of factions in the "Affiliation" dropdown is currently hardcoded, and also in a format specific to the SUI-React <Dropdown>
component. We can turn those into a Faction
model and extract those from the <UnitInfoForm>
component.
First, we'll create the model class and the sample data, then update the data loading reducer to parse in the Faction
entries.
import {Model, attr} from "redux-orm";
export default class Faction extends Model {
static modelName = "Faction";
static fields = {
id : attr(),
name : attr(),
};
static parse(factionData) {
return this.upsert(factionData);
}
}
designs : [
// ommitted
],
+ factions : [
+ {id : "cc", name : "Capellan Confederation"},
+ {id : "dc", name : "Draconis Combine"},
+ {id : "elh", name : "Eridani Light Horse"},
+ {id : "fs", name : "Federated Suns"},
+ {id : "fwl", name : "Free Worlds League"},
+ {id : "hr", name : "Hansen's Roughriders"},
+ {id : "lc", name : "Lyran Commonwealth"},
+ {id : "wd", name : "Wolf's Dragoons"},
+ ]
};
app/reducers/entitiesReducer.js
export function loadData(state, payload) {
// Create a Redux-ORM session from our entities "tables"
const session = orm.session(state);
// Get a reference to the correct version of model classes for this Session
- const {Unit, Pilot, Mech, MechDesign} = session;
+ const {Unit, Faction, Pilot, Mech, MechDesign} = session;
- const {unit, designs} = payload;
+ const {unit, factions, designs} = payload;
// Clear out any existing models from state so that we can avoid
// conflicts from the new data coming in if data is reloaded
- [Unit, Faction, Pilot, Mech, MechDesign].forEach(modelType => {
+ [Unit, Faction, Pilot, Mech, MechDesign].forEach(modelType => {
modelType.all().toModelArray().forEach(model => model.delete());
});
// Immutably update the session state as we insert items
Unit.parse(unit);
+ factions.forEach(faction => Faction.parse(faction));
designs.forEach(design => MechDesign.parse(design));
// Return the new "tables" object containing the updates
return session.state;
}
Next, now that factions are also a model type, our Unit.affiliation
field needs to change from a simple attribute to a foreign key reference to the Faction
table.
Commit 740e602: Update Unit affiliation to point to Factions
-import {Model, many, attr} from "redux-orm";
+import {Model, many, fk, attr} from "redux-orm";
export default class Unit extends Model {
static modelName = "Unit";
static fields = {
id : attr(),
name : attr(),
- affiliation : attr(),
+ affiliation : fk("Faction"),
color : attr(),
pilots : many("Pilot"),
mechs : many("Mech")
};
Now we can update the <UnitInfoForm>
component to read the list of factions from Redux and display them.
Commit 50aaf10: Use faction entries to populate affiliation dropdown
features/unitInfo/UnitInfo/UnitInfoForm.jsx
-const FACTIONS = [
- {value : "cc", text : "Capellan Confederation"},
- {value : "dc", text : "Draconis Combine"},
- {value : "elh", text : "Eridani Light Horse"},
- {value : "fs", text : "Federated Suns"},
- {value : "fwl", text : "Free Worlds League"},
- {value : "hr", text : "Hansen's Roughriders"},
- {value : "lc", text : "Lyran Commonwealth"},
- {value : "wd", text : "Wolf's Dragoons"},
-];
-const mapState = (state) => ({
- unitInfo : selectUnitInfo(state),
-});
+import {getEntitiesSession} from "features/entities/entitySelectors";
+const mapState = (state) => {
+ const session = getEntitiesSession(state);
+ const {Faction} = session;
+
+ const factions = Faction.all().toRefArray();
+
+ const unitInfo = selectUnitInfo(state);
+
+ return {factions,unitInfo};
+};
class UnitInfoForm extends Component {
render() {
- const {unitInfo, updateUnitInfo} = this.props;
+ const {unitInfo, updateUnitInfo, factions} = this.props;
const {name, affiliation, color} = unitInfo;
+ const displayFactions = factions.map(faction => {
+ return {
+ value : faction.id,
+ text : faction.name
+ };
+ });
Connecting the Unit Model 🔗︎
The <UnitInfoForm>
is now in an awkward situation. It's still showing data from state.unitInfo
, and state.unitInfo
in turn contains the entire unit
section from the sample data. If you inspect the current state tree, you'd see that we actually have all of the data arrays nested in there, like state.unitInfo.pilots
.
In addition, the inputs are also hooked up to apply updates to the state.unitInfo
section. We really need to change that so that the form displays the values from the Unit
model we've loaded in via Redux-ORM, and the updates are applied there as well.
For now, we'll assume that we only have a single Unit
model in memory. (Hopefully someday we'll get far enough that we can load multiple units at once, in which case that assumption will change, but it'll work for now.)
We'll start by changing the form to read the current Unit
entry and display its values instead of from state.unitInfo
.
Commit b08a248: Update unit info form to display current Unit details
features/unitInfo/unitInfoSelectors.js
import {createSelector} from "reselect";
import {getEntitiesSession} from "features/entities/entitySelectors";
export const selectUnitInfo = state => state.unitInfo;
export const selectCurrentUnitInfo = createSelector(
getEntitiesSession,
(session) => {
const {Unit} = session;
const currentUnitModel = Unit.all().first();
let currentUnitInfo = null;
if(currentUnitModel) {
currentUnitInfo = currentUnitModel.ref;
}
return currentUnitInfo;
}
)
We'll add a selector that knows how to read the plain JS object for the current Unit
. Since we assume that there's only one unit at a time, Unit.all().first()
should return us the model instance for that one unit, or undefined
if it doesn't exist. Assuming the unit instance does exist, we can grab the underlying plain JS object reference from the model instance.
features/unitInfo/UnitInfo/UnitInfoForm.jsx
import {getEntitiesSession} from "features/entities/entitySelectors";
-import {selectUnitInfo} from "../unitInfoSelectors";
+import {selectCurrentUnitInfo} from "../unitInfoSelectors";
import {updateUnitInfo, setUnitColor} from "../unitInfoActions";
const mapState = (state) => {
const session = getEntitiesSession(state);
const {Faction} = session;
const factions = Faction.all().toRefArray();
- const unitInfo = selectUnitInfo(state);
+ const unitInfo = selectCurrentUnitInfo(state);
return { factions, unitInfo };
};
class UnitInfoForm extends Component {
render() {
const {unitInfo, factions} = this.props;
+ const isDisplayingUnit = Boolean(unitInfo);
+ let name = "", affiliation = null, color = null;
+
+ if(isDisplayingUnit) {
+ ({name, affiliation, color} = unitInfo);
+ }
// omit other rendering code
<input
placeholder="Name"
name="name"
+ disabled={!isDisplayingUnit}
/>
Within the <UnitInfoForm>
, we switch up which selector we're using to retrieve the unit info values. Note that we could have used the same selector name and switched the way it was retrieving data, instead. This is an example of keeping the changing data storage abstracted from the component itself.
Inside the component, we're adding some additional logic to properly disable the input fields if there's no unit loaded into memory. If you look inside the if
clause, there's a neat little trick you can use to do destructuring assignment to variables that have already been declared using let
or var
- put parentheses around the entire statement. We could also have possibly handled the isDisplayingUnit
check by doing const {unitInfo = {}} = this.props
, and checking to see if it actually had any fields inside.
Now things get a bit interesting. Currently, our unitInfoReducer
is a slice reducer that handles updates to state.unitInfo
. What we need instead is a feature reducer that handles updates to the Unit
entry that's stored in state.entities.Unit
. Fortunately, the rewrite isn't overly complicated, thanks to the other reducer utility functions we already have available.
Commit 2a6592b: Rewrite unit info reducer to update current Unit model
features/unitInfo/unitInfoReducer.js
-import {createConditionalSliceReducer} from "common/utils/reducerUtils";
-import {DATA_LOADED} from "features/tools/toolConstants";
+import orm from "app/schema";
+import {createConditionalSliceReducer} from "common/utils/reducerUtils";
import {
UNIT_INFO_UPDATE,
UNIT_INFO_SET_COLOR,
} from "./unitInfoConstants";
-const initialState = {
- name : "N/A",
- affiliation : "",
- color : "blue"
-};
-function dataLoaded(state, payload) {
- const {unit} = payload;
- return unit;
-}
function updateUnitInfo(state, payload) {
- return {
- ...state,
- ...payload,
- };
+ const session = orm.session(state);
+ const {Unit} = session;
+
+ const currentUnit = Unit.all().first();
+
+ if(currentUnit) {
+ currentUnit.update(payload);
+ }
+
+ return session.state;
}
function setUnitColor(state, payload) {
const {color} = payload;
- return {
- ...state,
- color
- };
+ const session = orm.session(state);
+ const {Unit} = session;
+
+ const currentUnit = Unit.all().first();
+
+ if(currentUnit) {
+ currentUnit.color = color;
+ }
+ return session.state;
}
-export default createReducer(initialState, {
- [DATA_LOADED] : dataLoaded,
+export default createConditionalSliceReducer("entities", {
[UNIT_INFO_UPDATE] : updateUnitInfo,
[UNIT_INFO_SET_COLOR] : setUnitColor,
});
The diff might be a bit difficult to read, so here's what we did:
- We removed the
DATA_LOADED
constant and case reducer that just copied over theunit
section from the sample data - Both
updateUnitInfo()
andsetUnitColor()
case reducers now expect thatstate
is actually the entirestate.entities
slice. They create aSession
instance, look up theUnit
model, and update the appropriate fields before returning the updatedstate.entities
data. - The exported reducer now uses
createConditionalSliceReducer()
so that our case reducers only see thestate.entities
slice, and only if it's one of the action types this reducer knows how to handle.
const combinedReducer = combineReducers({
entities : entitiesReducer,
editingEntities : editingEntitiesReducer,
- unitInfo : unitInfoReducer,
pilots : pilotsReducer,
mechs : mechsReducer,
tabs : tabReducer,
modals : modalsReducer,
contextMenu : contextMenuReducer
});
const rootReducer = reduceReducers(
combinedReducer,
entityCrudReducer,
editingFeatureReducer,
+ unitInfoReducer,
);
With the unitInfoReducer
rewritten, we just need to update our root reducer so that we now longer have a state.unitInfo
slice, and instead have the unitInfoReducer
added to the sequential top-level "feature reducers" list.
At this point you might be thinking: "Hey, don't we already have a bunch of logic for updating models in the store?" Indeed, we do. However, as mentioned earlier, those assume that the model has been copied to state.editingEntities
first. We'll probably tackle that in the not-too-distant future, but for now it's simpler to let this be a special case where the edits are applied directly to the data that's in state.entities
.
Displaying Unit Data as a Tree 🔗︎
We're now ready to do something interesting and useful with the "Unit Table of Organization" tree. We're going to connect the tree so that it displays the lances in the unit and the pilots in each lance, based on the actual data in the Redux store, and we're going to load that data as a nested structure from our sample data.
Loading Lance Data 🔗︎
Again, our first step is to add a Lance
model.
import {Model, many, attr} from "redux-orm";
export default class Lance extends Model {
static modelName = "Lance";
static fields = {
id : attr(),
name : attr(),
pilots : many("Pilot")
};
static parse(lanceData) {
return this.upsert(lanceData);
}
}
Then we'll update the sample data to include info on the lance names and which pilots they contain, and parse that in as part of Unit
.
Commit 14cd310: Add lance data and parse lances when loading a Unit
const sampleData = {
unit : {
id : 1,
name : "Black Widow Company",
affiliation : "wd",
color : "black",
- lances : [],
+ lances : [
+ {
+ id : 1,
+ name : "Command Lance",
+ pilots : [
+ 1, 2, 3, 4
+ ]
+ },
+ {
+ id : 2,
+ name : "Fire Lance",
+ pilots : [
+ 5, 6, 7, 8
+ ]
+ },
+ {
+ id : 3,
+ name : "Recon Lance",
+ pilots : [
+ 9, 10, 11, 12
+ ]
+ }
+ ],
export default class Unit extends Model {
static modelName = "Unit";
static fields = {
id : attr(),
name : attr(),
affiliation : fk("Faction"),
color : attr(),
+ lances : many("Lance"),
pilots : many("Pilot"),
mechs : many("Mech")
};
static parse(unitData) {
- const {Pilot, Mech} = this.session;
+ const {Pilot, Mech, Lance} = this.session;
const parsedData = {
...unitData,
+ lances : unitData.lances.map(lanceEntry => Lance.parse(lanceEntry)),
pilots : unitData.pilots.map(pilotEntry => Pilot.parse(pilotEntry)),
mechs : unitData.mechs.map(mechEntry => Mech.parse(mechEntry)),
};
return this.upsert(parsedData);
}
}
Connecting the Unit Organization Tree 🔗︎
We already moved UnitOrganization.jsx
from features/unitOrganization
to features/unitInfo
. Now we'll move it into its own folder, because we're going to start extracting more components out of its current contents.
Commit 54fa32fb: Move UnitOrganizationTree into its own folder
Looking at the contents of the <UnitOrganizationTree>
component, we can see a lot of repetition in the structure. A good place to start would be extracting a separate component that displays a single pilot entry.
features/unitInfo/UnitOrganizationTree/LancePilot.jsx
import React from "react";
import {connect} from "react-redux";
import {
List,
} from "semantic-ui-react";
import {getEntitiesSession} from "features/entities/entitySelectors";
const mapState = (state, ownProps) => {
const session = getEntitiesSession(state);
const {Pilot} = session;
let pilot, mech;
if(Pilot.hasId(ownProps.pilotID)) {
const pilotModel = Pilot.withId(ownProps.pilotID);
pilot = pilotModel.ref;
if(pilotModel.mech) {
mech = pilotModel.mech.type.ref;
}
}
return {pilot, mech};
};
const UNKNOWN_PILOT = {name : "Unknown", rank : ""}
const UNKNOWN_MECH = {id : "N/A", name : ""};
const LancePilot = ({pilot = UNKNOWN_PILOT, mech = UNKNOWN_MECH}) => {
const {name, rank} = pilot;
const {id : mechModel, name : mechName} = mech;
return (
<List.Item>
<List.Icon name="user" />
<List.Content>
<List.Header>{rank} {name} - {mechModel} {mechName}</List.Header>
</List.Content>
</List.Item>
)
};
export default connect(mapState)(LancePilot);
We connect this component in the same way that we connected the <PilotsListRow>
component previously. The connected component will receive a pilotID
prop, and use that to look up the appropriate Pilot
model from the store. Assuming that a pilot by that ID exists, it will also look up the related Mech
entry so that we can display both the name of the pilot and the type of mech assigned to that pilot.
Since we have a fixed list of pilot entries right now, we can rewrite <UnitOrganizationTree>
to render individual <LancePilot>
components with fixed pilot IDs:
Commit 01e506b: Show hardcoded lance members by ID in the Unit TOE tree
features/unitInfo/UnitOrganizationTree/UnitOrganizationTree.jsx
<List.Content>
<List.Header>Command Lance</List.Header>
<List.List>
- <List.Item>
- <List.Icon name="user" />
- <List.Content>
- <List.Header>Cpt. Natasha Kerensky - WHM-6R Warhammer</List.Header>
- </List.Content>
- </List.Item>
+ <LancePilot pilotID={1} />
// etc
If we refresh the page, we'll see 12 rows of Unknown - N/A
lines inside of the tree until we load the pilot data. That's sort of good, because we know that the connected components are rendering and safely handling the case where the data doesn't exist.
The next step is to do the same thing for the lance entries. We'll extract the UI layout into a <Lance>
component, and have that component render a list of <LancePilot>
components based on the associated pilot IDs for that Lance
.
features/unitInfo/UnitOrganizationTree/Lance.jsx
import React from "react";
import {connect} from "react-redux";
import {
List,
} from "semantic-ui-react";
import {getEntitiesSession} from "features/entities/entitySelectors";
import LancePilot from "./LancePilot";
const mapState = (state, ownProps) => {
const session = getEntitiesSession(state);
const {Lance} = session;
let lance, pilots;
if(Lance.hasId(ownProps.lanceID)) {
const lanceModel = Lance.withId(ownProps.lanceID);
lance = lanceModel.ref;
pilots = lanceModel.pilots.toRefArray().map(pilot => pilot.id);
}
return {lance, pilots};
};
const UNKNOWN_LANCE = {name : "Unknown"}
const Lance = ({lance = UNKNOWN_LANCE, pilots = []}) => {
const {name} = lance;
const lancePilots = pilots.map(pilotID => <LancePilot key={pilotID} pilotID={pilotID} />);
return (
<List.Item>
<List.Icon name="cube" />
<List.Content>
<List.Header>{name}</List.Header>
<List.List>
{lancePilots}
</List.List>
</List.Content>
</List.Item>
)
};
export default connect(mapState)(Lance);
Pretty similar to the <LancePilot>
component, except that we now look up the list of pilots associated to the lance and extract an array of their IDs as a separate prop.
With that component created, we can simplify <UnitOrganizationTree>
further.
Commit d1d5a75: Show hardcoded lances by ID in the Unit TOE tree
features/unitInfo/UnitOrganizationTree/UnitOrganizationTree.jsx
<List.Content>
<List.Header>Black Widow Company</List.Header>
<List.List>
- <List.Item>
- <List.Icon name="cube" />
- <List.Content>
- <List.Header>Command Lance</List.Header>
- <List.List>
- <LancePilot pilotID={1} />
- <LancePilot pilotID={2} />
- <LancePilot pilotID={3} />
- <LancePilot pilotID={4} />
- </List.List>
- </List.Content>
- </List.Item>
+ <Lance lanceID={1} />
Again, a refresh of the page will show three "Unknown" entries in the tree, with no pilot children this time. Once we load the sample data, the entire tree should populate.
We're still showing a hardcoded list of lances, and that should really be driven based on the lances that are actually in the store.
Commit eca8838: Show lances in the Unit TOE tree based on current Unit entry contents
features/unitInfo/UnitOrganization/UnitOrganizationTree.jsx
import React from "react";
+import {connect} from "react-redux";
import {
List,
} from "semantic-ui-react";
+import {getEntitiesSession} from "features/entities/entitySelectors";
import Lance from "./Lance";
+const mapState = (state) => {
+ const session = getEntitiesSession(state);
+ const {Unit} = session;
+
+ let lances;
+
+ const unitModel = Unit.all().first();
+
+ if(unitModel) {
+ lances = unitModel.lances.toRefArray().map(lance => lance.id);
+ }
+
+ return {lances};
+}
-const UnitOrganizationTree = () => {
+const UnitOrganizationTree = ({lances = []}) => {
+ const lanceEntries = lances.map(lanceID => <Lance key={lanceID} lanceID={lanceID} />);
return (
<List size="large">
<List.Item>
<List.Icon name="cubes" />
<List.Content>
<List.Header>Black Widow Company</List.Header>
<List.List>
- <Lance lanceID={1} />
- <Lance lanceID={2} />
- <Lance lanceID={3} />
+ {lanceEntries}
</List.List>
</List.Content>
</List.Item>
</List>
)
}
export default connect(mapState)(UnitOrganizationTree);
Now when we refresh the page, the tree is empty except for the parent tree list item with the title "Black Widow Company". Loading the sample data will fill out the entire tree.
There's one final update to make here. Let's have that parent tree node render the current name of the unit, and show the affiliation color and values as well, so that we can see them update as we edit them in the <UnitInfoForm>
.
Commit cf8e19c: Show current unit detail values in the Unit TOE tree
features/unitInfo/UnitOrganizationTree/UnitOrganizationTree.jsx
const mapState = (state) => {
const session = getEntitiesSession(state);
const {Unit} = session;
- let lances;
+ let unit, faction, lances;
const unitModel = Unit.all().first();
if(unitModel) {
+ unit = unitModel.ref;
+ faction = unitModel.affiliation.ref;
lances = unitModel.lances.toRefArray().map(lance => lance.id);
}
- return {lances};
+ return {unit, faction, lances};
}
+const UNKNOWN_UNIT = {name : "Unknown"};
-const UnitOrganizationTree = ({lances = []}) => {
+const UnitOrganizationTree = ({unit = UNKNOWN_UNIT, faction = {}, lances = []}) => {
+ const {name, color} = unit;
+ const {name : factionName} = faction;
+ const colorBlock = <div
+ style={{
+ marginLeft : 10,
+ backgroundColor : color,
+ border : "1px solid black",
+ height : 20,
+ width : 40,
+ }}
+ />;
+ const displayText = factionName ? `${name} / ${factionName}` : name;
const lanceEntries = lances.map(lanceID => <Lance key={lanceID} lanceID={lanceID} />);
return (
<List size="large">
<List.Item>
<List.Icon name="cubes" />
<List.Content>
- <List.Header>Black Widow Company</List.Header>
+ <List.Header style={{display : "flex"}}>{displayText} {colorBlock}</List.Header>
<List.List>
{lanceEntries}
</List.List>
</List.Content>
</List.Item>
</List>
)
}
Notice that our tree component structure works the same way as our connected list: connected parent components using lists of IDs to render child components, and connected child components reading their own data from the store using the ID.
This tree is relatively simple, and has a fixed size, but the general pattern could be applied to a truly recursive tree as well. In fact, there's already a treeview example in the Redux repo. For background, see the PR from Dan Abramov adding the treeview example. (It's also interesting to read the discussion on that PR, as that's where the official guidance and collective wisdom really transitioned from "connect one component at the top of the component tree" to "connect many components deeper for better performance".)
Let's take a final look at the updated Unit Organization Tree:
Final Thoughts 🔗︎
I'm really excited to be continuing with this series. We're slowly starting to increase the level of complexity and difficulty in these examples, and I've got plenty more topics I want to cover. In the next couple parts, I hope to show how to edit complex nested/relational data, and then finally cover asynchronous logic and side effects.
As always, comments, feedback, and suggestions are greatly appreciated!
Further Information 🔗︎
- Upgrading to React 16
- Random Numbers in Redux
- Project Structuring and Containers
- Redux Treeviews
This is a post in the Practical Redux series. Other posts in this series:
- Jan 01, 2018 - Practical Redux, Part 11: Nested Data and Trees
- Nov 28, 2017 - Practical Redux course now available on Educative.io!
- Jul 25, 2017 - Practical Redux, Part 10: Managing Modals and Context Menus
- Jul 11, 2017 - Practical Redux, Part 9: Upgrading Redux-ORM and Updating Dependencies
- Jan 26, 2017 - Practical Redux, Part 8: Form Draft Data Management
- Jan 12, 2017 - Practical Redux, Part 7: Form Change Handling, Data Editing, and Feature Reducers
- Jan 10, 2017 - Practical Redux, Part 6: Connected Lists, Forms, and Performance
- Dec 12, 2016 - Practical Redux, Part 5: Loading and Displaying Data
- Nov 22, 2016 - Practical Redux, Part 4: UI Layout and Project Structure
- Nov 10, 2016 - Practical Redux, Part 3: Project Planning and Setup
- Oct 31, 2016 - Practical Redux, Part 2: Redux-ORM Concepts and Techniques
- Oct 31, 2016 - Practical Redux, Part 1: Redux-ORM Basics
- Oct 31, 2016 - Practical Redux, Part 0: Introduction