Modal for Adding Records

Part 18 of building a Rails 7 application

At the moment the only way to add new records is using the scaffolded "New" page. Not only is it pretty uninspiring to look at it, using it means navigating away from the index page to do so.

image.png

Instead I'll let users add records directly from the index page using a modal that contains a much better version of the form, the same as the "Edit" page in fact. Much of the work to make all this happen has been put in place in previous episodes of this series so this should be pretty straightforward.

Here's what I'll end up with:

First I need to tweak the existing "New" button on the index page. I already have a PageHeading component that takes care of rendering actions. I'll change the existing link_to over to a Button component:

app/views/item_sell_packs/index.html.erb

  <%= render PageHeading::Component.new(title: 'Item Sell Packs', description: 'These are the names for how a supplier sells a complete package') do |c| %>
    <%= c.with_actions do %>
      <%= render Button::Component.new(
          id: :item_sell_packs_new,
          label: 'New',
          options: {
              icon: { name: :plus, colour: :white },
              colour_classes: 'text-white bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-200',
              data: {
                  action: 'click->resource#new',
                  params: [
                      { name: 'resource-modal-name', value: :item_sell_packs_new }
                  ]
              }
          }
      ) %>
    <% end %>
  <% end %>

The Stimulus action is set to call the resource controller new method. I'm passing in the name of a modal too. I'll add that modal to the page next:

app/views/item_sell_packs/index.html.erb

  <%= render Modal::Component.new(name: :item_sell_packs_new) do |modal| %>
    <%= modal.with_form do %>
      <%= render(partial: 'resource', locals: { action: :new, resource: @resource_class.new, readonly: false, token: form_authenticity_token }) %>
    <% end %>
    <%= modal.with_button(
            id: :save_new,
            label: 'Save',
            options: {
                icon: { name: :save, colour: :white },
                colour_classes: 'text-white bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-200',
                data: {
                    params: [
                        { name: 'resource-url', value: polymorphic_url(@resource_class) },
                        { name: 'resource-form-id', value: dom_id(@resource_class.new, :form) },
                        { name: 'resource-modal-name', value: :item_sell_packs_new }
                    ],
                    action: 'click->resource#create'
                }
            }) %>
  <% end %>

Before wiring all that up I can add a Storybook story for the modal to see how it will look:

test/components/stories/modal/component_stories.rb

story :item_sell_packs_new do
      constructor(name: :item_sell_packs_new, hidden: false)
      form do
        render(partial: 'item_sell_packs/resource', locals: { action: :new, resource: ItemSellPack.new, readonly: false, token: 'token' })
      end
      button(
        id: :save_new,
        label: 'Save',
        options: {
          icon: { name: :save, colour: :white },
          colour_classes: 'text-white bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-200',
          data: {
            params: [
              { name: 'resource-url', value: '/item_sell_packs' },
              { name: 'resource-form-id', value: 'form_item_sell_pack' },
              { name: 'resource-modal-name', value: :item_sell_packs_new }
            ],
            action: 'click->resource#create'
          }
        }
      )
    end

image.png

Looks not too bad. Now on to adding the Stimulus code that will make the modal open and allow the form data to be POSTed. This is going to go into the resource controller I've used already, it has update and delete from previous sections I've covered.

app/javascript/controllers/resource_controller.js

  new(event) {
    const modalName = event.params.modalName
    const modal = cash(`#${modalName}_modal`)

    modal.show()
  }

  async create(event) {
    const resourceUrl = event.params.url
    const formId = event.params.formId
    const modalName = event.params.modalName
    const modal = cash(`#${modalName}_modal`)
    const formData = new FormData(document.getElementById(formId));

    const response = await post(resourceUrl, { body: formData, responseKind: 'json' })
    if (response.ok) {
      modal.hide()
    }
  }

There are two new methods, the first being new which simply finds the correct modal and shows it. The second method is for creating the new record. It finds the form, produces a new FormData from it and then POSTs to the correct URL. At the moment there is no error handling, it simply hides the modal if the create was successful.

Client side validation and error handling will be the topic of a future blog.

Creating the record is complete, the last user experience feature to add is to broadcast the creation of the new record. This will make the new record appear at the top of any screen showing the index page (including the user who made the record).

All this requires is adding another callback to the Broadcast concern created in the previous blog. This time it is called after a create has been committed on the model. And instead of a replace like the other callbacks this is a prepend which means it will insert a new row (in this case) at the beginning of the target collection_rows.

app/models/concerns/broadcast.rb

    after_create_commit lambda {
      broadcast_prepend_later_to(
        resource_name_plural,
        partial: "#{resource_name_plural}/row",
        locals: { resource: self },
        target: 'collection_rows'
      )
    }

That collection_rows target was added way back in the Infinite Scrolling blog entry.

app/components/collection/component.html.erb

<tbody id="collection_rows" class="bg-white" data-controller="resource">
  <%= rows %>
</tbody>

Finally I can fix up the existing test cases for creating new records as they will no longer work now that the original "New" button has been hijacked by the modal version. I can also add a test to ensure that the broadcast is adding the new record on to the page as well.

test/system/item_sell_packs_test.rb

  test 'should create item sell pack' do
    visit item_sell_packs_url
    click_on 'New'

    find('#item_sell_pack_new_canonical--toggle').click()
    fill_in 'Name', with: "a new pack"
    click_on 'Save'

    assert_selector("a", text: "a new pack")
  end