Hinchy
Posted on January 15, 2021
I primarily use Ruby on Rails at work, and Rails, like Ludo from The Labyrinth, is a large and very helpful beast. Did you want to create a model and all of its CRUD routes at the same time? Type in rails g scaffold Post title content
, run a migration and you're there.
On the Lucky framework, your hand isn't held quite so much. Creating CRUD functionality touches several sections of the framework which are covered separately in the docs - actions, pages, routes, and forms and so on - but as there aren't a squillion tutorials online (like there are for Rails) I found it a bit hard to grasp.
As a result, I'm putting it all in a single guide to try and show how everything hangs together in a simple CRUD app.
This guide won't get into the nitty gritty of Crystal or Lucky, nor is it exhaustive. Rather it's a way to draw parallels between Rails and Lucky through a commonly built, minimal feature (CRUD for a resource) in a way that would have helped me to learn Lucky coming from Rails.
The repo for this demonstration tipapp
project can be found here.
What The CRUD?
CRUD stands for Create, Read, Update and Delete. These are common actions that you may want to perform on a resource in an application.
What are we doing to do?
We're going to create a basic Lucky app which saves coding tips.
At work I pair a lot and I often see a colleague do something cool with their editor that I have no idea how to do. I ask them how to do it, they tell me, I nod, and the information promptly falls out of my ears. With this app, I'll never forget a handy tip again!
Before we start...
Make sure you have Crystal and Lucky installed. The official guide can be found here
When you go to a terminal, typing the commands lucky -v
, crystal -v
, psql --version
, node -v
and yarn -v
should return the version of Lucky, Crystal, Postgres, Node and Yarn you have installed respectively. If they don't error, you should be ready to roll.
Note
I've got my psql
database running locally in Docker on port 5432
. See the docker-compose.yml file on the example repo for an example if you don't want to install Postgres locally. If you do have Postgres running locally... ignore this note and continue living your life.
Step 1 - Create a new Lucky app 🌱
Related Documentation - Starting a Lucky Project
In your terminal, type lucky init
and you'll be taken through the setup of a new Lucky app.
- Enter the name of your project - I'm calling it
tipapp
- Choose whether you'd like it to be a 'full' app, or 'API Only' - Select
full
- Choose whether you'd like authentication generated for you - Select
y
The app should be generated for you.
- Run
cd tipapp
to navigate into your shiny new Lucky app. - Run
script/setup
to install all JS dependencies, Crystal shards, setup the DB and perform various other little machinations for your new project (warning: this may take a little while) - After setup is complete, run
lucky dev
and wait while your app shakes the dust from its shoulders and shambles on over tohttp://localhost:3001
.
Step 2 - Create the Tip Resource 🏭
Related Documentation - Database Models, Migrating Data
We need something to CRUD, so let's add the Tip
model.
Much like Rails, we get a generator with Lucky which we can use to create a model. Our Tip
model will have the following properties:
-
category
- eg Bash, SQL, Ruby -
description
- a detailed writeup of the tip -
user_id
- the ID of the User who created this Tip
We can generate this model and a few other required files (query, migration etc.) with the following command:
lucky gen.model Tip category:String description:String user_id:Int64
Review your migration file (eg tipapp/db/migrations/20210114223610_create_tips.cr
) to confirm the fields are correct, and then to put the new table in the database, run:
lucky db.migrate
All things going well, you'll now have a tips
table in your database.
To create the association between the User
and the Tip
, add the following to your tipapp/src/models/user.cr
file within the table block:
has_many tips : Tip
and within your Tip
file at tipapp/src/models/tip.cr
, inside the table block:
belongs_to user : User
Now our app knows that a Tip
belongs to a User
, and a User
can have many Tips
.
Step 3 - Seed Some Data! 🐿️
Related Documentation - Seeding Data
We want to seed User
and Tip
information to the database, so first, create a SaveUser
operation with the following content:
# tipapp/src/operations/save_user.cr
class SaveUser < User::SaveOperation
end
This will allow us to save a User
from our seed file.
Replace the call
method in your seeds file with:
# tipapp/tasks/create_sample_seeds.cr
def call
unless UserQuery.new.email("test-account@test.com").first?
SaveUser.create!(
email: "test-account@test.com",
encrypted_password: Authentic.generate_encrypted_password("password")
)
end
SaveTip.create!(
user_id: UserQuery.new.email("test-account@test.com").first.id,
category: "git",
description: "`git log --oneline` will display a lost of recent commit subjects, without the body"
)
SaveTip.create!(
user_id: UserQuery.new.email("test-account@test.com").first.id,
category: "vscode",
description: "`command + alt + arrow` will toggle between VS Code terminal windows"
)
puts "Done adding sample data"
end
and run lucky db.create_sample_seeds
Congratulations! That was a big step, but now you should have an app with authentication, a User
, some Tips
and the required associations between them.
Step 4.1 - The 'R' in CRUD - Creating an Index Page for all Tips 📝
Lucky uses Actions to route requests to their appropriate pages. To create an action for Tips::Index
, run:
lucky gen.action.browser Tips::Index
And head to http://localhost:3001/tips
to see the plaintext output of this file.
We want to render something a little more interesting than plaintext, so edit the Tips::Index
action to point at a yet-to-be-created page called Tips::IndexPage
, along with all of the current_user's tips:
# tipapp/src/actions/tips/index.cr
class Tips::Index < BrowserAction
get "/tips" do
html Tips::IndexPage, tips: UserQuery.new.preload_tips.find(current_user.id).tips
end
end
You should be getting type errors letting you know that we have no Tips::IndexPage
, so let's create it in the pages directory at tipapp/src/pages/tips/index_page.cr
. We'll render a simple table which iterates over the tips using the following code:
# tipapp/src/pages/tips/index_page.cr
class Tips::IndexPage < MainLayout
needs tips : Array(Tip)
def content
h1 "Tips"
table do
tr do
th "ID"
th "Category"
th "Description"
end
tips.each do |tip|
tr do
td tip.id
td tip.category
td tip.description
end
end
end
end
end
Check out http://localhost:3001/tips
to see your current_user's tips.
Step 4.2 - The 'R' in CRUD - Creating a Show page for a Tip 🖼️
This step will be pretty similar to the Index page. To create an action for the Tips
show page, run:
lucky gen.action.browser Tips::Show
And update the generated file to point at a Tips::Show
page, using the :tip_id
as param to find the relevant Tip
:
# tipapp/src/actions/tips/show.cr
class Tips::Show < BrowserAction
get "/tips/:tipid" do
html Tips::ShowPage, tip: TipQuery.new.user_id(current_user.id).find(tipid)
end
end
Next, create the Tips::Show
page and add the following code to show the Tip
information:
# tipapp/src/pages/tips/show_page.cr
class Tips::ShowPage < MainLayout
needs tip : Tip
def content
h1 "Tip ##{tip.id}"
para "Category: #{tip.category}"
para "Description #{tip.description}"
end
end
Visit the tips
path with the ID of a Tip - eg. http://localhost:3001/tips/2
and voila! A beautiful show page. Clearly, design is my passion.
Step 5 - The 'C' in CRUD - Creating a Tip 🧱
Now things are getting interesting! We want to be able to create a new Tip
by entering the information into a form at http://localhost:3001/tips/new
.
First, we need an Action that will handle that route:
lucky gen.action.browser Tips::New
And in that action, we want to create a new instance of SaveTip
and pass it to the Tips::NewPage
as operation
:
# tipapp/src/actions/tips/new.cr
class Tips::New < BrowserAction
get "/tips/new" do
html Tips::NewPage, operation: SaveTip.new
end
end
The Tips::NewPage
page should construct a form for the Tip
like so:
# tipapp/src/pages/tips/new_page.cr
class Tips::NewPage < MainLayout
needs operation : SaveTip
def content
h1 "Create New Tip"
form_for(Tip::Create) do
label_for(operation.category, "Category")
text_input(operation.category, attrs: [:required])
label_for(operation.description, "Description")
text_input(operation.description, attrs: [:required])
submit "Create Tip"
end
end
end
And just for something different, we need to permit the category
and description
params in the SaveTip Operation:
# tipapp/src/operations/save_tip.cr
class SaveTip < Tip::SaveOperation
permit_columns category, description
end
Whew! Done! Now navigate to the http://localhost:3001/tips/new
, create your new tip and you should see it once you're redirected to the http://localhost:3001/tips
page! This app is going to make billions!
Interlude - 🎶 Gettin' Linky wit' it 🎵
With all these routes, the app is a bit of a pain to navigate around at the moment. Let's add a few links to make our lives a bit easier.
- In the
tipapp/src/pages/tips/index_page.cr
file, create a link to the 'New Tip' page usinglink "New Tip", to: Tips::New
- In the
tipapp/src/pages/tips/index_page.cr
file, in the table of Tips, create a link to each Tip's show page usinglink "New Tip", to: Tips::New
- In the MainLayout page at
tipapp/src/pages/main_layout.cr
, add a link toTips::Index
after therender_signed_in_user
method so we can always get back to our Tips:
text " | "
link "Tips Index Page", to: Tips::Index
Now it certainly ain't pretty, but it's less of a hassle to navigate around the app.
Step 6 - The 'U' in CRUD - Updating a Tip 🎨
Once again, generate an Action, this time we want the action to display the form of the Tip
we'd like to update:
lucky gen.action.browser Tips::Edit
In that action, we want to get the ID of the Tip
we're editing, and return that as an argument to a Tips::EditPage
like so:
# tipapp/src/actions/tips/edit.cr
class Tips::Edit < BrowserAction
get "/tips/:tipid/edit" do
tip = TipQuery.new.user_id(current_user.id).find(tipid)
if tip
html Tips::EditPage, tip: tip, operation: SaveTip.new(tip)
else
flash.info = "Tip with id #{tipid} not found"
redirect to: Tips::Index
end
end
end
While we're at it, we need to create an action for the route that we'll send the updated Tip
information to, so create yet another action:
lucky gen.action.browser Tips::Update
And use this action to update the Tip
or redirect back to the Edit page if we have errors:
# tipapp/src/actions/tips/update.cr
class Tips::Update < BrowserAction
put "/tips/:tipid" do
tip = TipQuery.new.user_id(current_user.id).find(tipid)
SaveTip.update(tip, params) do |form, item|
if form.saved?
flash.success = "Tip with id #{tipid} updated"
redirect to: Tips::Index
else
flash.info = "Tip with id #{tipid} could not be saved"
html Tips::EditPage, operation: form, tip: item
end
end
end
end
Finally, we need to create the Tips::EditPage
with a form for the Tip we're updating:
# tipapp/src/pages/tips/edit_page.cr
class Tips::EditPage < MainLayout
needs tip : Tip
needs operation : SaveTip
def content
h1 "Edit Tip"
form_for(Tips::Update.with(tip)) do
label_for(@operation.category, "Category")
text_input(@operation.category, attrs: [:required])
label_for(@operation.description, "Description")
text_input(@operation.description, attrs: [:required])
submit "Update Tip"
end
end
end
So now put the ID of a Tip in your URL - eg http://localhost:3001/tips/5/edit
- and change the details to confirm you can update it!
Step 7 - The 'D' in CRUD - Deleting a Tip 💣
The final step! And thankfully, a simple one. First, add a Delete action:
lucky gen.action.browser Tips::Delete
And set up the action to destroy the Task based the ID passed in as a param:
# tipapp/src/actions/tips/delete.cr
class Tips::Delete < BrowserAction
delete "/tips/:tipid" do
tip = TipQuery.new.user_id(current_user.id).find(tipid)
if tip
tip.delete
flash.info = "Tip with id #{tipid} deleted"
else
flash.info = "Tip with id #{tipid} not found"
end
redirect to: Tips::Index
end
end
And that's it for deletion! We'll confirm it's all working in the next section.
Step 8 - Clean up and Confirm! 💅
Our app, if the stars have aligned, should be up and working. We just need a few more little niceties before we can fully test it out.
Firstly, on the Tips::Index page
, update the table to include links to the Show, Edit and Delete routes for each Tip:
# tipapp/src/pages/tips/index_page.cr
...
td do
ul do
li do
link "Edit", to: Tips::Edit.with(tip.id)
end
li do
link "Show", to: Tips::Show.with(tip.id)
end
li do
link "Delete", to: Tips::Delete.with(tip.id)
end
end
end
...
And on the Tips::Show
page at, add the same links so that the Tip
can be updated or deleted from there:
# tipapp/src/pages/tips/show_page.cr
ul do
li do
link "Edit", to: Tips::Edit.with(tip.id)
end
li do
link "Show", to: Tips::Show.with(tip.id)
end
li do
link "Delete", to: Tips::Delete.with(tip.id)
end
end
And that's it! Witness its magnificence!
To confirm it's working, go to the index page and create, update, and delete some tips.
Denouement ⌛
I'm thoroughly enjoying working with Lucky and Crystal. The addition of types is an incredible time-saver and the similarity to Ruby makes Crystal a pleasure to work with. With any luck the language will get a bit more popular and more guides like these will become available to get people up and running.
Any feedback or things I've gotten wrong, please let me know.
Posted on January 15, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.