Creating a Cheat Sheet Displayer
In a previous post, I mentioned that the intended use of a quick reference or cheat sheet (I've been flip-flopping about which to call them) is like regular reference material, except it should be even quicker and easier to access the most important and frequently used information. While a normal, static cheat sheet does an alright job, I felt like I could make it even faster and easier to reference information with a small web app so I decided to write one for the cheat sheets I have on thiswebsiteis.online.
With the web app you can view the cheat sheets, switch between the sheets, navigate within the sheet with a table of contents, and collect the tidbits of information you are currently using in a “workbench” where you can quickly navigate to and view the tidbits together. You can find the code for that little web app here.
I'll walk you through how I made the app and some of the things I encountered during development.
How I Made It
I knew I wanted it to be a small web app that wasn't much more than a prototype so I decided to use Clojurescript since I think Clojure, the language Clojurescript's based on, is a really great language for scripting and small projects. I also knew it was going to be a single page web app so I decided to use a Clojurescript port of React called Reagent. The project's code uses Reagent to create components out of the cheat sheet JSON, which is embedded in the page's HTML, and generates the table of contents, the display for the selected cheat sheet, and the workbench.
For the styling I decided to write it all from scratch rather than use a framework since it's a small project. I figured that creating all custom styling wouldn't be too difficult and would give it a more unique look. In hindsight, I kind of regret not using a framework since I have much more experience using something like Bootstrap to do things like the layout, and I would have rather finished it faster than have a slightly more “unique” look. I finished it eventually though and, despite not using a framework and not being a designer, I'm actually pretty happy with how it ended up looking.
Road Bumps
The basics of how it works is pretty simple, however some of the issues I ran into while working on the details were a bit more complicated. Here are a couple of the more interesting issues.
Linking to Other Sheets
Initially all of the links on the page were just going to be regular links that reload the page. However, since what was displayed depended on the app state rather than the URL the links wouldn't be able to go to any other sheet other than the default one. That was a problem for links in the workbench that I wanted to use to navigate to items in other sheets. To address this issue, I had to go back through and implement some basic routing. Basically, I used the history web api to turn the URL into something like a controlled component such that the browser's URL is the same as some local state representing the URL, which in turn is kept in sync with other relevant app state. Making this change had the added benefit of speeding up navigation, since the page doesn't do a complete reload now, and allowing bookmarked or shared URLs to locate a specific sheet and item. There are some Clojurescript routing libraries out there that I could have used for this, however I'm new to Clojurescript so I'm not really familiar with any of them yet, and this is a simple app so the routing wasn't very extensive.
EDN File Mystery
Currently the data is embedded into the HTML of the page as JSON, but at one point I was planning to have the app asynchronously request the data as an EDN file from the server. Even though that's not the design I ended up going with there was an interesting edge-case bug that occurred with that design that I thought would be worth mentioning.
Problem
When I was developing this part of the code in my cheat sheet app dev environment, it was getting the data correctly from the server by making a request for an .edn file that had the cheat sheet data in it. However, when I moved the code over to the website test environment, it was throwing an error about being unable to interpret the retrieved data. I realized that it wasn't deserializing the EDN file text into Clojure data in the test environment which was a little strange because the app was working in my dev environment. I figured I must have just accidentally removed the piece of code that deserialized the file text, clojure.edn/read-string
, so I just added it back in. However, when I ran the code with that function added back into my code in the dev environment it was throwing an error because the data was already deserialized and now the function was trying to deserialize already deserialized data.
At this point I was confused because the code and the data file in both the dev environment and test environment were exactly the same, so why would there be an error in one environment and not the other? Why would my code be getting deserialized data from the HTTP response for the EDN file without using clojure.edn/read-string
in the dev environment, but be getting the data as a serialized string from the response in the test environment?
Finding the solution
Upon on a little closer inspection I realized that in the dev environment, the shadow-cljs dev HTTP server was responding to requests for the EDN file with content-type: application/edn
in the response header, whereas on my site's test HTTP server it was responding with content-type: application/octet-stream
in the header.
Part of what I'm guessing was happening was that in my dev environment I was using the shadow-cljs HTTP server and that server probably recognizes the .edn filename extension because EDN files are commonly used with Clojure, which is what shadow-cljs primarily deals with, and so it puts content-type: application/edn
in the response header. However, application/edn
is not actually an IANA recognized media type and the basic python HTTP server I was using in my web site's test environment probably doesn't automatically recognize the .edn file type, since that file type isn't used as much outside of the Clojure ecosystem. Instead it uses the default content type for unknown file types: octet-stream
. (I didn't actually know that was the default content type for unknown file types before writing this post or else I might have realized the cause sooner).
The other part of what I'm guessing happened was that the cljs-http library I was using to make the HTTP request in Clojurescript recognized the application/edn
content type in the dev environment and automatically deserialized the body of the response, but just left the application/octet-stream
response's body as a string in the test environment.
So what ultimately caused the problem was the shadow-cljs server recognizing EDN files, that server using a non-standard content type in its response, cljs-http doing little bit of “magic” under the hood to automatically deserialize the data for that non-standard content type in the dev environment that I wasn't expecting, and other servers in the other environments not serving EDN files with that non-standard content type so they didn't automatically get deserialized.
Since the site host that I'm using for my “production environment" also serves the .edn file as content-type: application/octet-stream
I figured the code should work for the non-automatic deserializing scenario. I put the manual deserialization function into the code for that scenario and to get the code to work in my dev environment, I just changed the data file's .edn filename extension to .txt so the body of the response would be a string like in the other environments.
While that did fix that problem, I realized later that I didn't like the blank page that appeared while asynchronously retrieving the data, and, while I could have figured out how to put a placeholder while retrieving the data, I felt like embedding the small amount of data was easier and a better user experience so I ended up completely replacing the fix anyways (so much for not overengineering!). It was an interesting issue though that I thought was still worth mentioning.
Takeaways
After finishing the project there were a few things I learned and a few things I wish I had done differently from the start.
Component Composition
After a while, the amount of things that I had to pass down through the component props, i.e. prop drilling, started to become a bit of a headache. In the past I've used Redux which usually helps alleviate this problem, and is really the only method to alleviate prop drilling I had used before. However, I didn't really feel like using any of the ports or analogous libraries to Redux for Clojurescript (such as re-frame) because I wasn't really familiar with them and I didn't really feel like figuring out how to get them to work for just a small annoyance on a small project.
Looking around for alternatives to Redux for reducing prop drilling, the one I found suggested the most was component composition, which is basically just passing components into other components rather than constructing them within that component's definition. After learning more about it, it really seems like a pattern that the React design itself intended to be used more often than I've seen in practice so that seemed like a good alternative. However, most of the code had been written before I had discovered it, and I didn't really want to refactor and delay finishing longer than I already had so I just stuck with the existing code even if it's a bit uglier than I would have liked. Maybe next time, though, instead of immediately reaching for a version of redux, I'll try component composition instead.
Using Clojure
While I usually like Clojure for things like scripting, as I developed this app, I kind of regretted not using a language that I think is better for general application development like Scala because I found troubleshooting problems more difficult than I would have liked. The project might have just hit the threshold of complexity before I would really want to use a language with a decent static type system, and transpiled Clojurescript running in the browser sometimes gives some pretty cryptic error messages. Admittedly part of this problem might be because I'm not familiar enough with Clojurescript to troubleshoot and debug effectively yet. For example, I realized, only after I had pretty much finished, that I could actually use a step-by-step debugger with the Clojurescript code in the browser.
All in all, I'm pretty happy with how it turned out. It could have been more polished, since it's missing things like mobile/responsive styling and the items in the workbench are cut off a bit awkwardly, but since I was trying to just make a “minimum viable product” (and stop myself from overengineering more than I already did) I decided to call it finished for now. I would have also liked to have finished it a bit faster but I'm still relatively new to some of the main tools I used so I suppose going a bit slower than I expected was understandable. It also gave me some more experience with Clojure(script), Reagent, and even CSS so I should be faster next time. Hopefully you found the walkthrough of this project interesting and maybe it will inspire some people to create some cheat sheets of their own!