The Condition Editor is deployed at https://jamescdavis.github.io/condition-editor/
Welcome to my implementation of a Condition Editor UI. The goal of this app is to allow the user to filter a set of products by building a condition. The user first selects a property to filter on, then an operator (the list of which is dependent on the property type), followed by textual input for those operators that require it (or a select box for enumerated properties). Upon building a complete condition, the product list should refresh and be filtered by the user's condition. The deployed app uses mirage.js to simulate an actual API (lag included), but is really just running completely in your browser.
For this application, I chose to implement using the brand new Octane edition of Ember.js. I implemented all application code using TypeScript via ember-cli-typescript and avoided any use of any
(aside from the any
operator defined in this exercise). In additon, I used mirage.js/ember-cli-mirage, ember-power-select, ember-concurrency, ember-changeset, and ember-css-modules and a number of other addons and development tools.
Approximately 15-20 hours
I really only made one assumption, regarding UI behavior. It was unclear whether the user input should clear when changing properties and/or operators. In many cases it has to clear because it is invalid for the input type (e.g. random string for an enumerated property) so I implemented it to clear when either the property or operator changes.
Before I began writing any code, I spent some time thinking about the different types of data and how they related to each other and came up with a plan of attack. I decided to start on the models first, and build the app up from there.
The first tasks were setup. I ran ember new
to create a shiny new Ember application. I then immediately pinned node
and yarn
with volta
. Following this I installed ember-cli-typescript
and configured eslint
for TypeScript, based on airbnb-base
. I knew I'd be using ember-cli-mirage
for development and testing (and, in this case, production!), so I went ahead and installed it.
To get the provided datastore.js
loaded into the app, I placed it in vendor/
and added an app.import
to my ember-cli-build.js
to add it to my vendor bundle. Since I was using TypeScript, I created type declarations for window.datastore
. This would allow me to access it in a type-safe way. At this point, I also converted the remaining .js
files in app/
from ember new
to TypeScript so I'd have mostly pure TypeScript.
I wanted to have an easy way to run tests during development, so I configured a GitHub Action workflow for CI. It is triggered on evry push and runs linters and tests.I finished out prerequisite setup by installing/adding various type declarations I'd need such as jsonapi-typescript
and types for ember-cli-mirage
as well as setting up an empty mirage config.
It was now time to start creating ember-data
models. As I was using the most recent stable version of ember-data
that uses separate package imports, I quickly remembered that the type definitions haven't quite caught up, so I created basic definitions for @ember-data/model
.
The first model I created was property
. With no relationships of it's own, this would be the simplest of three models I had determined I needed. As I was now writing application code, I began to create a basic unit test. I find TypeScript enum
s invaluable, so I created one to represent property types (string
, number
, enumerated
). By creating a string-based enum
, I would have a nice, type-safe, way to refer to property types without having to haul strings around.
Next I created the product
model. This model really just relates a set of property-values
and contains no data on its own aside from the id
. I stubbed out a unit test for this model as well.
The final model I created was property-value
. This model relates product
and property
and holds the product's value for the property. The unit test I created for this model might be a bit superflous as it just checks that a property-value
can hold a value.
The last type of thing that I'd need to represent is operator
. As the set of operators are static, I chose to create a lib that exports an array of Operator
objects. Each Operator
started out with just its id and a compare function. I defined the operator ids as an enum
and also created a utility function for easily pulling an operator out of the list by id. The human-readable names of the operators live in window.datastore
. To load them, I created an Ember instance initializer that reads from window.datastore.getOperators()
and loads the names into the operator lib in memory. I followed up by creating unit tests for both the operator lib and the instance intializer. For the final part of this, I added a mapping of property types to operator to the property
model.
Now that I had created ways to represent data, I wanted to simulate an actual API rather than just loading director from the datastore. Mirage.js (formerly inseperable from ember-cli-mirage
) was the answer. For development, I ceated a mirage default scenario that loads from the datastore and populates the mirage in-memory database. I knew I would want to do the same in an acceptance test later, so I put the code for this importable utility functions. This gets data into mirage, but to get it out, I needed to configure endpoints.
The properties
endpoint required no special configuration as it is just for fetching the list of available properties. The products
endpoint, however, was a bit more complex as it needed to handle filtering based on property
, operator
, and, for most operators, a filter
input value. I decided these would be passed as query params when fetching products. In order to filter, I started with the full list of products in the mirage database and, for each, determined the property-value
for the specified property
. I then used the provided operator id to retrieve the specified operator and used its compare
function to test the property-value
against the provided filter
. In order to safely cast the operator query param string to an operator id enum, I created a custom typeguard, isOpId
. I now had a virtual "back end" on which to build the user interface.
I planned to use ember-concurrency
to handle async UI tasks, so I installed it along with ember-concurrency-decorators
and a set of type declarations. As this is an Ember Octane app, I wanted to use tracked
so I created a stub type definition for it. This app would only have a single route, so I just decided to use the application route. I created a controller for the this route that uses an ember-concurrency
task to load all the properties
on init
. I also created an accompanying unit test.
Now it was time to create some components. But first, I needed to install and config a few things. This included installing and/or configuring:
ember-cli-template-lint
stylelint
ember-css-modolues
@ember/render-modifiers
I decided to start with the product table first and so created a component for it. I quickly realized that I needed to add a few more type declarations for @ember-data/
and so created them. To be able to access a product's property values easily by property in the component template, I added a computed property to the product
model named propValsByPropId
that remaps product.propertyValues
into an object for easy access. The computed property's caching prevents this from needed to be recalculated every time its accessed. The ProductTable
component takes @property
, @operator
, and @filter
as arguments and passes these along when loading products. It uses the did-insert
render modifier to kick off the first load and did-update
watching both @operator
and @filter
because some operators don't require a filter. The result is then rendered as a styled html table. I also created the first component integration test for this project.
The next component I tackled was the property chooser, which is the the first step in the condition editor. For this, I decided to use ember-power-select
. This component is fairly simple. It takes a list of properties, the current property, and an action to perform when a property is chosen. It was so simple, in fact, that it could be a template-only component. The integration test, too is fairly simple.
The OperatorChooser
was the next component I created. This component is very similar to PropertyChooser
(it also uses ember-power-select
), but it also needs to look up the valid operators for a given property type. It takes @property
as an argument and uses a native getter called opsForProp
to retrieve the operator list for that property, which it then passes to PowerSelect
. This component also takes the current @operator
and an @onChange
action. Its integration test is a bit more complex, but not terribly so.
The final piece of the puzzle is a component I've called ConditionEditor
. This component brings together PropertyChooser
and OperatorChooser
, adds user input for the filter, and passes the built condition to ProductTable
. It does this by invoking the choosers, passing the appropiate data and actions, conditionally invoking the correct kind of input based on property and operator, and then yielding property
, operator
, and input
. This allows you invoke it as a block component and pass these values directly into the yielded block. This pattern is a little bit of a departure from Ember's traditional "data-down, actions up", but I find it useful in situations such as this where one component is essentially managing the state of another. A pure DDAU approach would pass in to ConditionEditor
actions to modify property
, operator
, and input
and these values would be stored on the parent that invokes both ConditionEditor
and ProductTable
(or passed down to a component that invokes one or both of them).
For user input, I like to use ember-changeset
rather than modify the underlying property directly. This has a number of benefits. I've used it here as part of debouncing (the underlying property update that triggers product re-load is debounced). It is also useful for validation before updating, which I added later. The changeset is built on did-insert
and re-built
when operator
changes (and operator
is reset when property
changes). To help determine the correct kind of input to render, I added some properties to operators and properties such as operator.needsInput
, property.isEnumerated
, and operator.multi
. The isEnumerated
property is calculated, so I added unit tests for it. The integration test for ConditionEditor
is quite extensive and originally contained a great deal of repetition. I created test helpers for selecting properties and operators, since it something I do a lot in that test and knew I would need for the acceptance test. I also created some helpers for testing what type of input was rendered.
Finally, I was at the point of putting it all together this simply involved invoking ConditionEditor
from the application route template and giving it a block invoking ProductTable
with its yielded values. At this point I had a fully functional app that met the requirements. I added an acceptance test that uses the provided datastore via the mirage utilities I created earlier. I discovered this test ran quite slow due to havig to wait for debounced input, so I disabled debouncing in the test environment and the whole test suite now finishes in about 45 seconds.
I decided, since I was already using ember-changeset
, to add user input validation. I installed ember-changeset-validations
for this and added some basic type declarations for it. I'm glad I added validation because it helped me discover a bug in comparing floats and integer values. I initially added validations for string.in
, number.equal
, number.greater_than
, number.less_than
, and string.in
. I ultimately dropped the "non-in" validations in favor of using type="number"
for the input, thus preventing invalid data entry on most modern browsers (and giving you a numpad on mobile!). I updated the ConditionEditor
integration test and the acceptance test to test for invalid input.
I wanted to deploy to a live site, so I installed and configured ember-cli-deploy
and ember-cli-deploy-git
to easily build and publish to GitHub pages. I enabled mirage in production so I would have data on my live site. Now with a ember deploy production
we're live!
There's something about putting your work out there that makes you want go back and polish it more. I spent some time tweaking CSS and making it more mobile-friendly (totally usable, IMHO). Playing with my app also helped my to find some annoyances with validation (as much as I love RegExes, sometimes, they just aren't the answer). I ended up ripping out the validateFormat
validators and replacing them with custom list validators that seem to work pretty well and are more true to what my fake back-end can acually handle. The final sign-off was to enable noEmitOnError
in TypeScript's compiler settings to break the build on type errors (I mean, I had it on the whole time ;). If this were a real app being worked on by a team, I'd definitely keep this enabled from the outset.
This section of the README outlines the details of collaborating on this Ember application.
You will need the following things properly installed on your computer.
git clone git@github.com:jamescdavis/condition-editor.git
cd condition-editor
yarn install
ember serve
- Visit your app at http://localhost:4200.
- Visit your tests at http://localhost:4200/tests.
Make use of the many generators for code, try ember help generate
for more details
ember test
ember test --server
yarn lint:hbs
yarn lint:js
yarn lint:js --fix
ember build
(development)ember build --environment production
(production)
ember deploy production