« Details: API implementation | The WarmAndFuzzy mobile app » |
The main page gives a quick run-down of what everything is up to (yeah, it's basically a database dump):
The other page any actual human might want to interact with is the thermostat settings page:
Each thermostat first lists its holds followed by its schedule steps (sorted by time-of-day).
The approach to editing a setting is heavily inspired by Bret Victor's manifesto Magic Ink: Information Software and the Graphical Interface from 2006. I've always wanted to build UI this way but repeatedly was dismissed for wanting to build some sort of "MadLibs" craziness. Well, for better or worse, there's nobody here to tell me what to do, so here we are.
Every element of a setting (e.g. Weekdays at 07:00, heat to 24°C) is clickable and uses a pop-up for editing. The only thing keeping this from being fully word-based is that I use color-coded iconography instead of "heat to" etc. since I think it's easier to parse visually and it takes up less space (and it still works for color-blind people).
The system does let you select arbitrary days (e.g. just Mondays and Wednesdays), but it's also smart enough to collapse the obvious combinations of days into "weekdays", "weekends", and "everyday".
Once a setting is changed, an undo and save button are injected into the setting "bean" for the modified setting:
There's not a lot that folks can adjust about how they experience the system individually; I have begrudgingly added the ability to experience the system through irrational units (Fahrenheit) rather than the sane and reasonable system default of Celsius.
All of the stuff that gets touched during system setup and then never again lives under the System Configuration
tab and it looks basically like the database UI that it is, plus proper units annotations.
This was the first editing UI I created so I'm just phoning it in with a Modal:
The web app is built using React and uses semantic-ui-react
as its foundational UX element library. I was tired of everything in the world looking like Bootstrap but hadn't quite realized that semantic-ui-react
seems to be close to abandoned. It does work fine, though certain things like its handling of themes and colors is kind of half baked (e.g. you cannot set custom colors for elements). ++
okay but would not buy again.
The single greatest move forward in the app(s) was to move my data and state into a MobX store, and to generalize GraphQL access via MobX-based helper classes.
Here's how this breaks down:
AuthStore
is a centralized store for maintaining authentication state and tokens.AuthStore
doesn't do the authentication itself, it exposes a reference to an AuthProvider
, an interface implemented by the web and mobile app code (different implementations because Auth0 needs different kinds of babysitting in R vs. RN). AuthStore
just acts as a link to AuthProvider
so that application code has a simple way of (e.g.) requesting logins, etc. without me having to stand up yet another React context provider. Anyhow.StoreBase
provides the basic structure that any store should follow:@observable
state
enum ("fetching" | "updating" | "ready" | "error"
).@computed
semantic accessors for that state so I can expand the state definitions in the future, e.g. isWorking
currently covers fetching
and updating
, but could also cover, say, deleting
, if that became a thing in the future.error
and lastUpdated
properties for the obvious.name
for debugging purposes.GraphqlStoreBase
is a templatized (generic) implementation of StoreBase
for read-only GraphQL-sourced stores. It is configured with the type of the stored item and the query.async
function is pretty extra (private readonly fetchData = flow(function*(this: GraphqlStoreBase<T, TQuery>) {...}
) so this was nice to have to do just once.autorun
in the constructor referencing the auth state from the AuthStore
. If a user logs in, the store automatically fetches data; if a user logs out, it automatically clears itself. This chaining of observable state and actions across MobX stores is just hella slick.GraphqlStoreBase
requires that stored items have an id
string property and offers a findItemById
function. The function just performs a linear search since our data is so small that I cannot imagine that this is worth doing any better.latestThermostatValues
is a good example of a consumer (or rather, actual implementor) of GraphqlStoreBase
, including its use of QueryResultDataItemPatcher<T>
to fix up return items (rehydrating Date
types).GraphqlMutableStoreBase
derives from GraphqlStoreBase
and provides an updateItem(id)
function, again leveraging the fact that every item has an id
.ThermostatSettingsStore
is a good example of a consumer (or rather, again, actual implementor) of GraphqlMutableStoreBase
.StoreBase
, to go back to the beginning for a minute, also makes it easy to build a helper like StoreChecks
, a component for the web and mobile apps to guard pages/components/etc. that rely on stores to be loaded. It just accepts anything that's a StoreBase
.RootStore
owns all of the stores.useRootStore()
is made available as a React context provider to all components.AuthProvider
.AuthStore
based on that AuthProvider
.AuthProvider
which AuthStore
it should push updates intoAuthStore
to have a readonly
reference to the AuthProvider
, thus this order.ApolloClient
which will auto-inject the access token from the AuthStore
into each outgoing request and use another slick-ass MobX reaction
(since we didn't need a full-on autorun
) to clear the Apollo cache whenever a user logs out.RootStore
.RootStoreContext.Provider
.This system is really lovely for this application. Structurally, it has a few downsides:
However, there just isn't a lot of data in our system to begin with. Order-of-magnitude of ten thermostats, clients on WiFi, on-the-wire compression - it's just not worth doing better. Each app (mobile or web) has more or less the same capabilities so they might as well share exactly the same stores.
Side note: The web app is the only place that accesses timeseries data (thermostat and sensor streams); that access is managed through dedicated stores since they're filtered by the UI and it is high-volume data.
The Graphql[Mutable]StoreBase
infrastructure is designed for stores of many/multiple items so when it came to build the UserPreferencesStore
I had to improvise a bit, since a user only gets a single set of strongly typed preferences. I used the fixup capabilities of GraphqlStoreBase
and GraphqlMutableStoreBase
to respectively inject and remove an id
into the type and data of the strongly typed preferences item, and offered it up for consumers through a @computed
userPreferences
property. It's slightly weird but a detailed comment documents it well enough and saves me from hundreds of lines of code duplication.
My preferred pattern has become to use useState
to set up mutable copy of the store item being modified, using that for isDirty
detection as well, and then writing back into the store whenever the Save button is pushed.
A simple application of this is for the UserPreferences
page:
const UserPreferences: React.FunctionComponent = (): React.ReactElement => {
const rootStore = useRootStore();
const authStore = rootStore.authStore;
const userPreferencesStore = rootStore.userPreferencesStore;
const userFirstName = authStore.userName?.split(" ")[0];
const userPreferences = userPreferencesStore.userPreferences;
const [mutableUserPreferences, setMutableUserPreferences] = useState(userPreferences);
const [isSaving, setIsSaving] = useState(false);
const isUserPreferencesDirty = !UserPreferencesSchema.UserPreferencesIsEqual(
userPreferences,
mutableUserPreferences
);
return (
<StoreChecks requiredStores={[userPreferencesStore]}>
<Container style={{ paddingTop: "2rem" }} text>
<Header as="h4" attached="top" block>
Let&apos;s get this right for you, {userFirstName}.
</Header>
<Segment attached>
<Form loading={isSaving}>
<Form.Group>
<Form.Select
fluid
label="Preferred temperature units"
onChange={(
_event: React.SyntheticEvent<HTMLElement>,
data: DropdownProps
): void => {
setMutableUserPreferences({
...mutableUserPreferences,
temperatureUnits: data.value as GraphQL.TemperatureUnits,
});
}}
options={[
GraphQL.TemperatureUnits.Celsius,
GraphQL.TemperatureUnits.Fahrenheit,
].map(
(temperatureUnit): DropdownItemProps => {
return { key: temperatureUnit, value: temperatureUnit, text: temperatureUnit };
}
)}
value={mutableUserPreferences.temperatureUnits}
/>
</Form.Group>
<Form.Button
content={isSaving ? "Saving..." : "Save"}
disabled={!isUserPreferencesDirty}
icon="save"
onClick={async (): Promise<void> => {
setIsSaving(true);
await userPreferencesStore.updateUserPreferences(mutableUserPreferences);
setIsSaving(false);
}}
positive
/>
</Form>
</Segment>
</Container>
</StoreChecks>
);
};
export default observer(UserPreferences);
React Router is pretty dope for managing getting around the app. AuthenticatedRoute
is a neat little trick for bouncing unauthenticated callers back to the main page.
If you need a cleaner example of interacting with Auth0 than any sample I've been able to find on the internet (if I may say so), look at Auth
.
Assume that most architecture shown in Getting started!-type guides on the internet is not very good.
The React-ish/Functional-ish programming paradigm seems to still be foreign to many engineers, which is entirely fair considering that we're all taught imperative languages from the on-set so the one-directional state flow / single assignment philosophy feels really different.
(I was fortunate to have been taught how to write fairly functional-ish C++ (const
all the things!) by a very smart co-worker early on, so this is somewhat more natural to me.)
Once you've wrapped your newly melted brain around React's approach to state (which they of course keep changing refine their guidance on every six months, bless their hearts) you have to contend with the reality that dogmatic top-down data flow also doesn't work super-well past toy scale.
You've got a logout button at the bottom of the UI hierarchy that somehow needs to find the auth component to request a logout, then the auth component somehow needs to find the GraphQL component to tell it to clear its cache, and then the GraphQL component (or whatever) needs to find other local stores to erase all their contents, and then the UI needs to reflorgle itself in the absence of all that data.
It's just a bloody nightmare, and it's no wonder that basically no Getting started! guide gets it right. They generally get a single part of the system right (logging in or out, or querying data, or storing data) but tying it all is invariably in danger of becoming spaghetti code.
MobX makes this make sense by allowing for loosely coupled state dependencies that still let each part of the system be as Functional-ish as it wants to be.
To be clear, WarmAndFuzzy didn't burst onto the scene as a perfectly formed MobX codebase. I suffered without it for a long time and it was a pretty painful and lengthy PR of 50 commits across 93 changed files to do nothing but make state management not suck.
Escape hatches are dope.
The fact that my GraphQL store foundation classes give the implementor the opportunity to transmogrify each item as it's read or written made it easy to re-use that infrastructure for the UserPreferencesStore
. Architecture should be opinionated and purpose-built; further, offering tightly-scoped escape hatches greatly enhances reusability. JavaScript's Functional-ish pervasive use of lambdas makes it a lot easier to write stateless transform functors for just this purpose.
(And yes, just because it should be stateless doesn't prevent anyone from passing in something more complex, for better or worse, but C++ has the same problem. Ah well.)
« Details: API implementation | The WarmAndFuzzy mobile app » |