Setting up a CRUD app in Lucky

hinchy

Hinchy

Posted on January 15, 2021

Setting up a CRUD app in Lucky

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.

Alt Text

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 to http://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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and within your Tip file at tipapp/src/models/tip.cr, inside the table block:

belongs_to user : User
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

show Tip page

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 using link "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 using link "New Tip", to: Tips::New
  • In the MainLayout page at tipapp/src/pages/main_layout.cr, add a link to Tips::Index after the render_signed_in_user method so we can always get back to our Tips:
text "    |    "
link "Tips Index Page", to: Tips::Index
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And that's it! Witness its magnificence!

The finished product!

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.

💖 💪 🙅 🚩
hinchy
Hinchy

Posted on January 15, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Setting up a CRUD app in Lucky
lucky Setting up a CRUD app in Lucky

January 15, 2021