Rails: Table Selection Form (Article 2 of 2)

Article 2 of 2: Dynamic Table Selection in combination with Filtering and Sorting

This article uses: https://btihen.dev/posts/ruby/rails_8_0_rails_tables_filtering_sorting as a starting point.

Aticle 1 of 2: Modern Rails: Table Filtering & Sorting Article 2 of 2: Modern Rails: Table Selection Form

The code for this article can be found at: https://github.com/btihen-dev/rails_table_selection_form

Basic Rails App Setup

Be sure you have a database (I assume postgresql), but feel free to reconfigure to your favorite database - this project is not database specific.

It assumes Rails 8.0.1 (but should work with 7.1+) It also assumes Ruby 3.4.2 (but should work with 3.2+)

git clone https://github.com/btihen-dev/rails_table_filtering_sorting.git
cd rails_table_filtering_sorting
bundle install
yarn install
rails db:create
rails db:migrate
rails db:seed

See Modern Rails: Table Filtering & Sorting article to understand the code up to this point.

Row Selector

First we will add a checkbox to the column (for a select all):

  <th scope="col">
  Select All
    <%= check_box_tag "select-all", nil, false %>
  </th>

and a checkbox in each row (to select the row)

  <td scope="row">
    <%= check_box_tag "selected_rows[]",
        character.id,
        false, # not selected
        id: "selected_rows_#{character.id}"
    %>
  </td>

I this case we need to set the id manually to ensure it is unique - since the name selected_rows[] would usually be the id, but this will be repeated many times we help rails by adding a manual id using id: "selected_rows_#{character.id}"

So now the index page will look like this:

# app/views/characters/index.html.erb
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<p style="color: green"><%= notice %></p>

<% content_for :title, "Characters" %>

<h1>Characters</h1>

<div id="characters" class="text-center">

  <table class="table table-striped table-hover">
    <thead class="sticky-top">
      <tr class="table-primary">
        <th scope="col" class="align-top">
          Select<br>
          <div class="form-check form-switch">
          <%= check_box_tag "select-all",
              nil,
              false, # todo: are all displayed rows selected?
              class: "form-check-input"
          %>
          </div>
        </th>
        <th scope="col" class="align-top">
          ID <%= sort_link(column: "characters.id") %>
        </th>
        <th scope="col" class="align-top">
          Last Name <%= sort_link(column: "last_name") %><br>
          <%= render "form_match_filter", field_name: :last_name_filter, placeholder: "partial last name" %>
        </th>
        <th scope="col" class="align-top">
          First Name <%= sort_link(column: "first_name") %><br>
          <%= render "form_match_filter", field_name: :first_name_filter, placeholder: "partial first name" %>
        </th>
        <th scope="col" class="align-top">
          Gender <%= sort_link(column: "gender") %><br>
          <%= render "form_dropdown_filter", field_name: :gender_selection, options: Character.distinct.pluck(:gender).compact %>
        </th>
        <th scope="col" class="align-top">
          Species <%= sort_link(column: 'species.species_name') %><br>
          <%= render "form_dropdown_filter", field_name: :species_selection, options: Species.pluck(:species_name, :id) %>
        </th>
        <th scope="col" class="align-top">
          Company <%= sort_link(column: 'companies.company_name') %><br>
          <%= render "form_match_filter", field_name: :company_filter, placeholder: "partial company name" %>
        </th>
      </tr>
    </thead>

    <tbody class="scrollable-table">
      <% @characters.each  do |character| %>
        <tr id="<%= dom_id(character) %>" class="align-middle">
          <td scope="row">
            <div class="form-check form-switch"">
              <%= check_box_tag "selected_rows[]",
                  character.id,
                  @selected_rows.include?(character.id), # is row selected?
                  id: "selected_row_#{character.id}", # unique id
                  class: "form-check-input"
              %>
            </div>
          </td>
          <th scope="row"><%= link_to character.id, edit_character_path(character) %></th>
          <td><%= character.last_name %></td>
          <td><%= character.first_name %></td>
          <td><%= character.gender %></td>
          <td><%= character.species.species_name %></td>
          <td class="text-start">
            <% character.character_jobs.each  do |character_job| %>
              <div class="job-container mb-2">
                <div class="company-row">
                  <div class="h6"><b><%= character_job.job.company.company_name %></b></div>
                </div>
                <div class="job-details-row">
                  <span style="font-weight: 600;">- <%= character_job.job.role %></span>
                  <span>
                    <em>
                      (from: <%= character_job.start_date.strftime("%e %b '%y") %>
                      to: <%= character_job.end_date&.strftime("%e %b '%y") || 'present' %>)
                    </em>
                  </span>
                </div>
              </div>
            <% end  %>
          </td>
        </tr>
      <% end %>
    </tbody>
  </table>

</div>

<%= link_to "New character", new_character_path, class: "btn btn-primary" %>

now run bin/dev and open your browser and go to http://localhost:3000 be sure you see a list of characters like:

add_selection_switches

Unfortunately, we can only select the switches, but nothing happens.

Row Selection

To select a row we need to identify the the row and add it to the URL and of course we will need a form to submit the selected rows.

Add Selected Route

We will need a new routee to handle the selected rows - using:

post "/selected_characters", to: "selected_characters#index"

So now the router will look like:

# config/routes.rb
Rails.application.routes.draw do
  resources :jobs
  resources :companies
  resources :characters
  resources :species
  post "/selected_characters", to: "selected_characters#index"

  get "up" => "rails/health#show", as: :rails_health_check

  root "characters#index"
end

Add Selected Controller

Let’s build the controller:

# app/controllers/selected_characters_controller.rb
class SelectedCharactersController < ApplicationController
  def index
    selected_rows = params[:selected_rows] || params['selected_rows']
    selected_ids =
      case selected_rows
      when Array
        selected_rows.map(&:to_i) || []
      when String
        selected_rows.to_s.split(',').map(&:to_i).reject(&:zero?)
      else
        []
      end

    # base query (remember not to create an N+1 query)
    @selected_characters =
      Character
        .includes(:species)
        .includes(character_jobs: { job: :company })
        .where(id: selected_ids)

    respond_to do |format|
      # format.html # Render the selected.html.erb view
      format.html { render :index }
      format.json { render json: @selected_characters }
    end
  end
end

Selected Template

Now we need to create a template for the selected characters.

# app/views/selected_characters/index.html.erb
<p style="color: green"><%= notice %></p>

<h1>Selected Characters</h1>

<div id="characters" class="container text-center">

<table class="table table-striped table-hover">
  <thead class="sticky-top">
    <tr class="table-primary">
      <th scope="col">ID</th>
      <th scope="col">First Name</th>
      <th scope="col">Last Name</th>
      <th scope="col">Gender</th>
      <th scope="col">Species</th>
      <th scope="col">Company</th>
    </tr>
  </thead>

  <tbody class="scrollable-table">
    <% @selected_characters.each  do |character| %>
      <tr id="<%= dom_id(character) %>">
        <th scope="row"><%= link_to character.id, edit_character_path(character) %></th>
        <td><%= character.first_name %></td>
        <td><%= character.last_name %></td>
        <td><%= character.gender %></td>
        <td><%= character.species.species_name %></td>
        <td class="text-start">
          <% character.character_jobs.each do |character_job| %>
            <div class="job-container mb-2">
              <div class="company-row">
                <div class="h6"><b><%= character_job.job.company.company_name %></b></div>
              </div>
              <div class="job-details-row">
                <span style="font-weight: 600;">- <%= character_job.job.role %></span>
                <span>
                  <em>
                    (from: <%= character_job.start_date.strftime("%e %b '%y") %>
                    to: <%= character_job.end_date&.strftime("%e %b '%y") || 'present' %>)
                  </em>
                </span>
              </div>
            </div>
          <% end %>
        </td>
      </tr>
    <% end %>
  </tbody>
</table>

</div>

Add Selected Row Form

Now we need to add a form to submit the selected rows to the Characters template:

# app/views/characters/index.html.erb
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<p style="color: green"><%= notice %></p>

<% content_for :title, "Characters" %>

<h1>Characters</h1>

<div id="characters" class="text-center">
  <%= form_with url: selected_characters_path, method: :post,
      data: { controller: "characters-table", turbo: false }, local: true do |form| %>

    <div class="d-flex justify-content-end mb-3">
      <%= form.submit "Submit Selected", class: "btn btn-primary" %>
    </div>

    <%= hidden_field_tag :selected_rows, params[:selected_rows] %>

    <table class="table table-striped table-hover">
      ...
    </table >

  <% end %>
</div>

<%= link_to "New character", new_character_path, class: "btn btn-primary" %>

And Stimulus to JS buttons:

<!-- select all -->
<%= check_box_tag "select-all",
    nil,
    false,
    class: "form-check-input",
    data: {
      action: "characters-table#toggleSelectAll",
      "characters-table-target": "selectAll"
    }
%>

<!-- row selection -->
<%= check_box_tag "row-selector",
    character.id,
    @selected_rows.include?(character.id), # is row selected?
    id: "row-selector_#{character.id}", # unique id
    data: {
      action: "change->characters-table#selectRow",
      "characters-table-target": "rowSelector"
    },
    class: "form-check-input"
%>

Now it should look like:

# app/views/characters/index.html.erb
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<p style="color: green"><%= notice %></p>

<% content_for :title, "Characters" %>

<h1>Characters</h1>

<div id="characters" class="text-center">
<%= form_with url: selected_characters_path, method: :post,
    data: { controller: "characters-table", turbo: false }, local: true do |form| %>

  <div class="d-flex justify-content-end mb-3">
    <%= form.submit "Submit Selected", class: "btn btn-primary" %>
  </div>

  <%= hidden_field_tag :selected_rows, params[:selected_rows] %>

  <table class="table table-striped table-hover">
    <thead class="sticky-top">
      <tr class="table-primary">
        <th class="align-top">
          Select<br>
          <div class="form-check form-switch">
          <%= check_box_tag "select-all",
              nil,
              false,
              class: "form-check-input",
              data: {
                action: "characters-table#toggleSelectAll",
                "characters-table-target": "selectAll"
              }
          %>
          </div>
        </th>
        <th class="align-top">
          ID <%= sort_link(column: "characters.id") %>
        </th>
        <th class="align-top">
          Last Name <%= sort_link(column: "last_name") %><br>
          <%= render "form_match_filter", field_name: :last_name_filter, placeholder: "partial last name" %>
        </th>
        <th class="align-top">
          First Name <%= sort_link(column: "first_name") %><br>
          <%= render "form_match_filter", field_name: :first_name_filter, placeholder: "partial first name" %>
        </th>
        <th class="align-top">
          Gender <%= sort_link(column: "gender") %><br>
          <%= render "form_dropdown_filter",
              field_name: :gender_selection, options: Character.distinct.pluck(:gender).compact %>
        </th>
        <th class="align-top">
          Species <%= sort_link(column: 'species.species_name') %><br>
          <%= render "form_dropdown_filter",
                     field_name: :species_selection, options: Species.pluck(:species_name, :id) %>
        </th>
        <th class="align-top">
          Company <%= sort_link(column: 'companies.company_name') %><br>
          <%= render "form_match_filter", field_name: :company_filter, placeholder: "partial company name" %>
        </th>
      </tr>
    </thead>

    <tbody class="scrollable-table">
      <% @characters.each  do |character| %>
        <tr id="<%= dom_id(character) %>" class="align-middle">
          <td>
            <div class="form-check form-switch"">
              <%= check_box_tag "row-selector",
                  character.id,
                  @selected_rows.include?(character.id), # is row selected?
                  id: "row-selector_#{character.id}", # unique id
                  data: {
                    action: "change->characters-table#selectRow",
                    "characters-table-target": "rowSelector"
                  },
                  class: "form-check-input"
              %>
            </div>
          </td>
          <th scope="row"><%= link_to character.id, edit_character_path(character) %></th>
          <td><%= character.last_name %></td>
          <td><%= character.first_name %></td>
          <td><%= character.gender %></td>
          <td><%= character.species.species_name %></td>
          <td class="text-start">
            <% character.character_jobs.each  do |character_job| %>
              <div class="job-container mb-2">
                <div class="company-row">
                  <div class="h6"><b><%= character_job.job.company.company_name %></b></div>
                </div>
                <div class="job-details-row">
                  <span style="font-weight: 600;">- <%= character_job.job.role %></span>
                  <span>
                    <em>
                      (from: <%= character_job.start_date.strftime("%e %b '%y") %>
                      to: <%= character_job.end_date&.strftime("%e %b '%y") || 'present' %>)
                    </em>
                  </span>
                </div>
              </div>
            <% end  %>
          </td>
        </tr>
      <% end %>
    </tbody>
  </table>

<% end %>
</div>

<%= link_to "New character", new_character_path, class: "btn btn-primary" %>

Stimulus Controller

Fix Filters

Let’s test our existing filters before we add the row selection.

now we see any filter changes will submit the selected rows form - not just the filter form.

// app/javascript/controllers/characters-table_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["selectAll", "rowSelector"];

  filter(event) {
    clearTimeout(this.timeout);
    this.timeout = setTimeout(() => {
      this.element.requestSubmit();
    }, 300);
  }
}

The problem is the requestSubmit() method will submit the selected_rows form too let’s fix this using Turbo.visit() so this function should now look like:

// app/javascript/controllers/characters-table_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["selectAll", "rowSelector"];

  filter(event) {
    clearTimeout(this.timeout);
    this.timeout = setTimeout(() => {
      const currentParams = new URLSearchParams(window.location.search);
      // Suppose the changed filter input has `name="last_name_filter"`
      // and its new value is in event.target.value
      currentParams.set(event.target.name, event.target.value);

      // Merge other existing params if needed (like selected_rows) -
      // this visit persists the selected rows - even those not visible
      Turbo.visit(`${window.location.pathname}?${currentParams.toString()}`, {
        action: "replace", // or 'advance'
      });
    }, 300);
  }
}

Now the filters and sorting should work as expected again.

Add Row Selection Controll

Now we need to update our Stimuulus controller to handle the row selection. This should do the trick.

// app/javascript/controllers/characters-table_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["selectAll", "rowSelector"];
  connect() {
    // console.log("Controller connected");
    this.initializeSelectedRows();
  }
  filter(event) {
    // console.log("filter change");
    clearTimeout(this.timeout);
    this.timeout = setTimeout(() => {
      const currentParams = new URLSearchParams(window.location.search);
      // Suppose the changed filter input has `name="last_name_filter"`
      // and its new value is in event.target.value
      currentParams.set(event.target.name, event.target.value);

      // this now submits only to the filter form and not the selected_rows form
      Turbo.visit(`${window.location.pathname}?${currentParams.toString()}`, {
        action: "replace", // or 'advance'
      });
    }, 300);
  }
  selectRow(event) {
    // console.log("Row selected");
    if (!this.hasRowSelectorTarget) {
      console.error("No row selector targets found");
      return;
    }
    this.updateSelectedRows();
  }
  initializeSelectedRows() {
    // console.log("initializeSelectedRows");
    const urlParams = new URLSearchParams(window.location.search);
    const selectedRows = urlParams.get("selected_rows");
    // console.log("Initializing selected rows from URL:", selectedRows);
    if (selectedRows) {
      const selectedRowIds = selectedRows.split(",");
      this.rowSelectorTargets.forEach((checkbox) => {
        if (selectedRowIds.includes(checkbox.value)) {
          checkbox.checked = true;
        }
      });
    }
  }
  updateSelectedRows() {
    // console.log("Updating selected rows");
    const selectedRows = [];
    this.rowSelectorTargets.forEach((checkbox) => {
      if (checkbox.checked) {
        selectedRows.push(checkbox.value);
      }
    });
    const url = new URL(window.location);
    if (selectedRows.length > 0) {
      url.searchParams.set("selected_rows", selectedRows.join(","));
    } else {
      url.searchParams.delete("selected_rows");
    }
    console.log("Updating URL with selected rows:", selectedRows);
    // Update the URL without reloading the page
    window.history.replaceState({}, "", url);
  }
}

Test - be sure the selected checkboxes are shown in the URL when clicked, persist after a page reload and after a filter change!

selected_rows_in_url

Select All

Now our previous function filter is to niave as it uses requestSubmit which will submit ANY form on the page - we only want it to submit the filter form. So let’s rewrite it to use Turbo.visit instead.

So now it should look like this:

  toggleSelectAll() {
    // console.log("toggleSelectAll");
    const isChecked = this.selectAllTarget.checked;
    this.rowSelectorTargets.forEach((checkbox) => {
      checkbox.checked = isChecked;
    });
    this.updateSelectedRows();
  }

Now the full JS controller should look like:

// app/javascript/controllers/characters-table_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["selectAll", "rowSelector"];
  connect() {
    // console.log("Controller connected");
    this.initializeSelectedRows();
  }
  filter(event) {
    // console.log("filter change");
    clearTimeout(this.timeout);
    this.timeout = setTimeout(() => {
      const currentParams = new URLSearchParams(window.location.search);
      // Suppose the changed filter input has `name="last_name_filter"`
      // and its new value is in event.target.value
      currentParams.set(event.target.name, event.target.value);

      // only submits to the filter form instead of all forms on the page
      Turbo.visit(`${window.location.pathname}?${currentParams.toString()}`, {
        action: "replace", // or 'advance'
      });
    }, 300);
  }
  selectRow(event) {
    // console.log("Row selected");
    if (!this.hasRowSelectorTarget) {
      console.error("No row selector targets found");
      return;
    }
    this.updateSelectedRows();
  }
  toggleSelectAll() {
    // console.log("toggleSelectAll");
    const isChecked = this.selectAllTarget.checked;
    this.rowSelectorTargets.forEach((checkbox) => {
      checkbox.checked = isChecked;
    });
    this.updateSelectedRows();
  }
  initializeSelectedRows() {
    // console.log("initializeSelectedRows");
    const urlParams = new URLSearchParams(window.location.search);
    const selectedRows = urlParams.get("selected_rows");
    // console.log("Initializing selected rows from URL:", selectedRows);
    if (selectedRows) {
      const selectedRowIds = selectedRows.split(",");
      this.rowSelectorTargets.forEach((checkbox) => {
        if (selectedRowIds.includes(checkbox.value)) {
          checkbox.checked = true;
        }
      });
    }
  }
  updateSelectedRows() {
    // console.log("Updating selected rows");
    const selectedRows = [];
    this.rowSelectorTargets.forEach((checkbox) => {
      if (checkbox.checked) {
        selectedRows.push(checkbox.value);
      }
    });
    const url = new URL(window.location);
    if (selectedRows.length > 0) {
      url.searchParams.set("selected_rows", selectedRows.join(","));
    } else {
      url.searchParams.delete("selected_rows");
    }
    console.log("Updating URL with selected rows:", selectedRows);
    // Update the URL without reloading the page
    window.history.replaceState({}, "", url);
  }
}

Test - select all (and deselect all) - should only affect the visible rows! should persist between page loads and filter changes.

Fix html (show as checked when all visible rows are selected)

    <%= check_box_tag "select-all",
        nil,
        @all_visible_selected,
        class: "form-check-input",
        data: {
          action: "characters-table#toggleSelectAll",
          "characters-table-target": "selectAll"
        }
    %>,

in the controller we now need to add the new instance variable that compares the visible @characters to the @selected_rows using:

  def index
    ...
    # execute query
    @characters = query.all
    @all_visible_selected = @characters.all? { |c| @selected_rows.include?(c.id) }
  end

now the controller should look like:

# app/controllers/characters_controller.rb
class CharactersController < ApplicationController
  before_action :set_character, only: %i[ show edit update destroy ]

  # GET /characters or /characters.json
  def index
    # query with sorting
    column = params[:column]
    direction = params[:direction]
    @selected_rows = params[:selected_rows]&.split(',')&.map(&:to_i) || []

    # base query
    query = Character
            .includes(:species)
            .includes(character_jobs: { job: :company })

    # add sort if direction is given
    query = if direction == 'none' || column.blank?
              query.order('characters.id')
            else
              query.order("#{column} #{direction}")
            end

    # partial match filters
    @first_name_filter = params[:first_name_filter]
    @last_name_filter = params[:last_name_filter]
    @company_filter = params[:company_filter]
    query = query.where('characters.first_name ilike ?', "%#{@first_name_filter}%") if @first_name_filter.present?
    query = query.where('characters.last_name ilike ?', "%#{@last_name_filter}%") if @last_name_filter.present?
    query = if @company_filter.present?
              query.joins(character_jobs: { job: :company }).where('companies.company_name ilike ?', "%#{@company_filter}%")
            else
              query
            end

    # Dropdown selections
    @gender_selection = params[:gender_selection]
    @species_selection = params[:species_selection]
    query = query.where(gender: @gender_selection) if @gender_selection.present?
    query = query.where(species_id: @species_selection) if @species_selection.present?

    # execute query
    @characters = query.all
    @all_visible_selected = @characters.all? { |c| @selected_rows.include?(c.id) }
  end

Test

selected_characters_unsorted

Extra - sort selected characters like in form

Let’s allow our selection show in the same sort order as the form.

add

    sort_column = params['column'] || :id
    sort_direction = params['direction'] || :asc

    # add sort if direction is given
    query = if direction == 'none' || column.blank?
              query.order('characters.id')
            else
              query.order("#{column} #{direction}")
            end

So now the controller would look like:

# app/controllers/selected_characters_controller.rb
class SelectedCharactersController < ApplicationController
  def index
    selected_rows = params['selected_rows'] || params[:selected_rows]
    selected_ids =
      case selected_rows
      when Array
        selected_rows.map(&:to_i).reject(&:zero?)
      when String
        selected_rows.to_s.split(',').map(&:to_i).reject(&:zero?)
      else
        []
      end

    # base query
    query = Character
            .includes(:species)
            .includes(character_jobs: { job: :company })
            .where(id: selected_ids)

    # add sort if sort_direction is given
    sort_column = params['column'] || params[:column] || :id
    sort_direction = params['direction'] || params[:direction] || :asc
    query = if sort_direction == 'none' || sort_column.blank?
              query.order('characters.id')
            else
              query.order("#{sort_column} #{sort_direction}")
            end

    @selected_characters = query

    respond_to do |format|
      format.html { render :index }
      format.json { render json: @characters }
    end
  end
end

Now update the view with the hidden inputs to pass sort_info to the controller:

  <%= hidden_field_tag :sort_column, params[:column] %>
  <%= hidden_field_tag :sort_direction, params[:direction] %>

So now the view should looks like:

<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<p style="color: green"><%= notice %></p>

<% content_for :title, "Characters" %>

<h1>Characters</h1>

<div id="characters" class="text-center", data-controller="characters-table">
<!-- <div id="characters" class="text-center"> -->
<%= form_with url: selected_characters_path, method: :post,
    data: { controller: "characters-table", turbo: false }, local: true do |form| %>

  <div class="d-flex justify-content-end mb-3">
    <%= form.submit "Submit Selected", class: "btn btn-primary" %>
  </div>

  <!-- send sort info to controller -->
  <%= hidden_field_tag :sort_column, params[:column] %>
  <%= hidden_field_tag :sort_direction, params[:direction] %>
  <!-- send selected rows to controller -->
  <%= hidden_field_tag :selected_rows, params[:selected_rows] %>

  <table class="table table-striped table-hover">

Test

selected_characters_unsorted

Resources

Adding Bootstrap to an existing project

Bootstrap Icons

Rails Table Articles

Rails Hotwire

APPENDIX

when using esbuild you should see the following two files and you should NOT see import-map files or config!

David Colby who wrote: https://www.colby.so/posts/turbo-8-refresh-sorting and https://www.colby.so/posts/filtering-tables-with-rails-and-hotwire

writes:

For reference, Procfile.dev looks like this for me on a fresh esbuild + bootstrap install:

web: env RUBY_DEBUG_OPEN=true bin/rails server
js: yarn build --watch
css: yarn watch:css

And package.json looks like this:

{
  "name": "app",
  "private": true,
  "dependencies": {
    "@hotwired/stimulus": "^3.2.2",
    "@hotwired/turbo-rails": "^8.0.4",
    "@popperjs/core": "^2.11.8",
    "autoprefixer": "^10.4.19",
    "bootstrap": "^5.3.3",
    "bootstrap-icons": "^1.11.3",
    "esbuild": "^0.20.2",
    "nodemon": "^3.1.0",
    "postcss": "^8.4.38",
    "postcss-cli": "^11.0.0",
    "sass": "^1.76.0"
  },
  "scripts": {
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets",
    "build:css:compile": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules",
    "build:css:prefix": "postcss ./app/assets/builds/application.css --use=autoprefixer --output=./app/assets/builds/application.css",
    "build:css": "yarn build:css:compile && yarn build:css:prefix",
    "watch:css": "nodemon --watch ./app/assets/stylesheets/ --ext scss --exec \"yarn build:css\""
  },
  "browserslist": [
    "defaults"
  ]
}
Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature