Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using Materialize `chip` and `autocomplete` in Ruby on Rails Form with Associated Models

I am trying to create a form so a user can save a setting which has their default teams (multiple) and their professions (single). I can do this using simple_form and the lines of code below, but I am trying to use autocomplete as the dropdown lists do not work well with my design.

  • <%= f.association :profession %>
  • <%= f.association :team, input_html: { multiple: true } %>

I am loading the JSON from a collection into an attribute data-autocomplete-source within my inputs, a short bit of jquery then cycles through each of these and then initialises the materialize .autocomplete, I also need to do this with .chips for many associations.

The UI element is working as I would like, but I cannot work out how to save a new record. I have two problems:

  1. Unpermitted parameters: :team_name, :profession_name - I've been trying to adapt this tutorial and believed that Step 11 would effectively translate this within the model, but am clearly not understanding something...
  2. "setting"=>{"team_name"=>"", "profession_name"=>"Consultant Doctor"} - the team_name values (i.e. the chips) are not being recognised when attempting to save the record. I've got some nasty jquery that transfers the id from the div to the generated input which I was hoping would work...

I've also checked many previous questions on Stack Overflow (some of which seem to be similar to this question, generally using jqueryui) but cannot work out how to adapt the answers.

How can I use the names from a model in a materialize chip and autocomplete input and save the selections by their associated id into a record?

Any help or guidance would be much appreciated.


setting.rb

class Setting < ApplicationRecord

  has_and_belongs_to_many :team, optional: true

  belongs_to :user
  belongs_to :profession

  def team_name
    team.try(:name)
  end

  def team_name=(name)
    self.team = Team.find_by(name: name) if name.present?
  end

  def profession_name
    profession.try(:name)
  end

  def profession_name=(name)
    self.profession = Profession.find_by(name: name) if name.present?
  end


end

settings_controller.rb

  def new

    @user = current_user
    @professions = Profession.all
    @teams = Team.all
    @setting = Setting.new

    @teams_json = @teams.map(&:name)
    @professions_json = @professions.map(&:name)

    render layout: "modal"

  end


  def create

    @user = current_user
    @setting = @user.settings.create(setting_params)

    if @setting.save 
      redirect_to action: "index"
    else
      flash[:success] = "Failed to save settings"
      render "new"   
    end

  end


  private

    def setting_params
      params.require(:setting).permit(:user_id, :contact, :view, :taketime, :sortname, :sortlocation, :sortteam, :sortnameorder, :sortlocationorder, :sortteamorder, :location_id, :profession_id, :department_id, team_ids: [])
    end

views/settings/new.html.erb

<%= simple_form_for @setting do |f| %>



<div class="row">
  <div class="col s12">
    <div class="row">
      <div class="input-field autocomplete_dynamic col s12">
        <i class="material-icons prefix">group</i>

        <div data-autocomplete-source='<%= @teams_json %>' class="string optional chips" type="text" name="setting[team_name]" id="setting_team_name"></div>

      </div>
    </div>
  </div>
</div>




<div class="row">
  <div class="col s12">
    <div class="row">
      <div class="input-field autocomplete_dynamic col s12">
        <i class="material-icons prefix">group</i>

          <%= f.input :profession_name, wrapper: false, label: false, as: :search, input_html: {:data => {autocomplete_source: @professions_json} } %>

        <label for="autocomplete-input">Select your role</label>
      </div>
    </div>
  </div>
</div>



  <%= f.submit %>

<% end %>

$("*[data-autocomplete-source]").each(function() {

  var items = [];
  var dataJSON = JSON.parse($(this).attr("data-autocomplete-source"));

  var i;
  for (i = 0; i < dataJSON.length; ++i) {
    items[dataJSON[i]] = null;
  }

  if ($(this).hasClass("chips")) {

    $(this).chips({
      placeholder: $(this).attr("placeholder"),
      autocompleteOptions: {
        data: items,
        limit: Infinity,
        minLength: 1
      }
    });


    // Ugly jquery to give the generated input the correct id and name
    idStore = $(this).attr("id");
    $(this).attr("id", idStore + "_wrapper");
    nameStore = $(this).attr("name");
    $(this).attr("name", nameStore + "_wrapper");

    $(this).find("input").each(function() {
      $(this).attr("id", idStore);
      $(this).attr("name", nameStore);
    });


  } else {

    $(this).autocomplete({
      data: items,
    });

  }

});
.prefix~.chips {
  margin-top: 0px;
}
<!-- jquery -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

<!-- Materialize CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">

<!-- Materialize JavaScript -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>

<!-- Material Icon Webfont -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">




<div class="row">
  <div class="col s12">
    <div class="row">
      <div class="input-field autocomplete_dynamic col s12">
        <i class="material-icons prefix">group</i>

        <div data-autocomplete-source='["Miss T","Mr C","Mr D","Medicine Take","Surgery Take"]' class="string optional chips" type="text" name="setting[team_name]" id="setting_team_name"></div>


      </div>
    </div>
  </div>
</div>




<div class="row">
  <div class="col s12">
    <div class="row">
      <div class="input-field autocomplete_dynamic col s12">
        <i class="material-icons prefix">group</i>

        <input class="string optional input-field" data-autocomplete-source='["Consultant Doctor","Ward Clerk","Nurse","Foundation Doctor (FY1)","Foundation Doctor (FY2)","Core Trainee Doctor (CT2)","Core Trainee Doctor (CT1)"]' type="text" name="setting[profession_name]"
          id="setting_profession_name">


        <label for="autocomplete-input">Select your role</label>
      </div>
    </div>
  </div>
</div>

Gems and versions

  • ruby '2.5.0'
  • gem 'rails', '~> 5.2.1'
  • gem 'materialize-sass'
  • gem 'material_icons'
  • gem 'materialize-form'
  • gem 'simple_form', '>= 4.0.1'
  • gem 'client_side_validations'
  • gem 'client_side_validations-simple_form'
like image 936
Oliver Trampleasure Avatar asked Dec 18 '18 19:12

Oliver Trampleasure


1 Answers

This is almost certainly not the best way of doing this, but it does work. Please offer suggestions and I will update this, or if someone adds a better answer I will happily mark it as correct. This solution doesn't require much in the way of controller/model changes and is largely done with a (comparatively) short bit of jquery/JS so can be easily repeated within a project.


I've managed to get both autocomplete and chips working with Ruby on Rails, utilising simple_form form helpers where possible.

Effectively I am storing the JSON into a custom attribute for each case and then parsing this with some jquery/javascript when the view is loaded before using this to initialise either autocomplete or chips.

Autocomplete values are translated from name to id within the controller.

Chip values are recognised client side with some JS, and inputs created with the correct name and id for simpleform to automatically save the values as an array to the hash.

Full explaination and code is below.

Thank you to Tom for his helpful comments and input.


autocomplete

Requires you to create an input under variable_name and then add additional functions in the model to translate the name into an id for saving. Effectively following this tutorial.

<%= f.input :profession_name,  input_html: { data: { autocomplete: @professions_json  } } %>

As you can see above, the only real difference from adding a typical simple_form association is the following:

  • f.input rather than f.association - ensures a textbox is rendered rather than a drop down
  • :model_name rather than :model - ensures that the controller recognises this is a name that needs to be converted into an object
  • input_html: { data: { autocomplete: @model_json } } - this adds a custom attribute with all your JSON data, this is parse by

You need to ensure the names of your model are unique.


chips

This is a little more involved, requiring extra javascript functions. The code attachs a callback to the event of adding or removing a chip, before cycling through each and adding a hidden input. Each input has a name attribute which matches what simple_form expects, so it is correctly added to the hash params before being submitted to the controller. I couldn't get it to translate multiple names in an array, so just got it to re-read the id from the original JSON and add that as the value of the input.

  <div id="team_ids" placeholder="Add a team" name="setting[team_ids]" class="chips" data-autocomplete="<%=  @teams_json %>"></div>

From above you can see there are the following deviations from simple_form convention:

  • <div> rather than a <% f.input %> as Materialize chips needs to be called on a div
  • placeholder="..." this text is used as a placeholder once the chips is initialised, this can be left blank / not included
  • name="setting[team_ids]" helps simple_form understand which model this applies to
  • class="chips" ensures that our javascript later knows to initialise chips on this element
  • data-autocomplete="<%= @teams_json %>" saves the JSON data as an attribute of the div for parsing later

Currently the code re-parses the original JSON attribute, it is possible to reference the JSON data that is created on initialisation of the chips, this is likely better but I could not get it to work.

Custom Input Element - someone more experience than myself might be able to play around with this and create a custom element for simple_form... it was beyond me unfortunately.


Ruby on Rails Code

settings_controller.rb

class SettingsController < ApplicationController

  ...

  def new

    @user = current_user
    @setting = Setting.new
    @professions = Profession.select(:name)
    @teams = Team.select(:id, :name)

    # Prepare JSON for autocomplete and chips
    @teams_json = @teams.to_json(:only => [:id, :name] )
    @professions_json = @professions.to_json(:only => [:name] )

  end


  ....

 private
    def setting_params
      params.require(:setting).permit( :profession_name, :user_id,  :profession_id, team_ids: [])
    end

setting.rb

class Setting < ApplicationRecord

  has_and_belongs_to_many :teams, optional: true    
  belongs_to :user
  belongs_to :profession, optional: true

  def profession_name
    profession.try(:name)
  end

  def profession_name=(name)
    self.profession = Profession.find_by(name: name) if name.present?
  end

_form.html.erb N.B. this is a partial, as denoted by the preceding underscore

<%= simple_form_for @setting, validate: true, remote: true  do |f| %>

  <%= f.input :profession_name,  input_html: { data: { autocomplete: @professions_json  } } %>

  <div id="team_ids" placeholder="Add a team" name="setting[team_ids]" class="chips" data-autocomplete="<%=  @teams_json %>"></div>

  <%= f.submit %>

<% end %>

Demo

$(document).ready(function() {

  // Cycle through anything with an data-autocomplete attribute
  // Cannot use 'input' as chips must be innitialised on a div
  $("[data-autocomplete]").each(function() {

    var dataJSON = JSON.parse($(this).attr("data-autocomplete"));

    // Prepare array for items and add each
    var items = [];
    var i;
    for (i = 0; i < dataJSON.length; ++i) {
      items[dataJSON[i].name] = null; // Could assign id to image url and grab this later? dataJSON[i].id
    }


    // Check if component needs to be a chips
    if ($(this).hasClass("chips")) {

      // Initialise chips
      // Documentation: https://materializecss.com/chips.html
      $(this).chips({
        placeholder: $(this).attr("placeholder"),
        autocompleteOptions: {
          data: items,
          limit: Infinity,
          minLength: 1
        },
        onChipAdd: () => {
          chipChange($(this).attr("id")); // See below
        },
        onChipDelete: () => {
          chipChange($(this).attr("id")); // See below
        }
      });


      // Tweak the input names, etc
      // This means we can style the code within the view as we would a simple_form input
      $(this).attr("id", $(this).attr("id") + "_wrapper");

      $(this).attr("name", $(this).attr("name") + "_wrapper");

    } else {

      // Autocomplete is much simpler! Just initialise with data
      // Documentation: https://materializecss.com/autocomplete.html
      $(this).autocomplete({
        data: items,
      });

    }




  });

});


function chipChange(elementID) {

  // Get chip element from ID
  var elem = $("#" + elementID);

  // In theory you can get the data of the chips instance, rather than re-parsing it
  var dataJSON = JSON.parse(elem.attr("data-autocomplete"));

  // Remove any previous inputs (we are about to re-add them all)
  elem.children("input[auto-chip-entry=true]").remove();

  // Find the wrapping element
  wrapElement = elem.closest("div[data-autocomplete].chips")

  // Get the input name we need, [] tells Rails that this is an array
  formInputName = wrapElement.attr("name").replace("_wrapper", "") + "[]";

  // Start counting entries so we can add value to input
  var i = 0;

  // Cycle through each chip
  elem.children(".chip").each(function() {

    // Get text of chip (effectively just excluding material icons 'close' text)
    chipText = $(this).ignore("*").text();

    // Get id from original JSON array
    // You should be able to check the initialised Materialize data array.... Not sure how to make that work
    var chipID = findElement(dataJSON, "name", chipText);

    // ?Check for undefined here, will be rejected by Rails anyway...?

    // Add input with value of the selected model ID
    $(this).parent().append('<input value="' + chipID + '"  multiple="multiple" type="hidden" name="' + formInputName + '" auto-chip-entry="true">');


  });

}


// Get object from array of objects using property name and value
function findElement(arr, propName, propValue) {
  for (var i = 0; i < arr.length; i++)
    if (arr[i][propName] == propValue)
      return arr[i].id; // Return id only
  // will return undefined if not found; you could return a default instead
}


// Remove text from children, etc
$.fn.ignore = function(sel) {
  return this.clone().find(sel || ">*").remove().end();
};


// Print to console instead of posting
$(document).on("click", "input[type=submit]", function(event) {

  // Prevent submission of form
  event.preventDefault();

  // Gather input values
  var info = [];
  $(this).closest("form").find("input").each(function() {
    info.push($(this).attr("name") + ":" + $(this).val());
  });

  // Prepare hash in easy to read format
  var outText = "<h6>Output</h6><p>" + info.join("</br>") + "</p>";
  
  // Add to output if exists, or create if it does not
  if ($("#output").length > 0) {
    $("#output").html(outText);
  } else {
    $("form").append("<div id='output'>" + outText + "</div>");
  }


});
<!-- jquery -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

<!-- Materialize CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">

<!-- Materialize JavaScript -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>

<!-- Material Icon Webfont -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">



<form class="simple_form new_setting" id="new_setting" novalidate="novalidate" data-client-side-validations="" action="/settings" accept-charset="UTF-8" data-remote="true" method="post"><input name="utf8" type="hidden" value="✓">


  <div class="input-field col string optional setting_profession_name">
  <input data-autocomplete='[{"id":1,"name":"Consultant Doctor"},{"id":2,"name":"Junior Doctor (FY1)"}]' class="string optional" type="text" name="setting[profession_name]" id="setting_profession_name"
      data-target="autocomplete-options-30fe36f7-f61c-b2f3-e0ef-c513137b42f8" data-validate="true">
      <label class="string optional" for="setting_profession_name">Profession name</label></div>

  <div id="team_ids" name="setting[team_ids]" class="chips input-field" placeholder="Add a team" data-autocomplete='[{"id":1,"name":"Miss T"},{"id":2,"name":"Surgical Take"}]'></div>


  <input type="submit" name="commit" value="Create Setting" data-disable-with="Create Setting">

</form>
like image 82
Oliver Trampleasure Avatar answered Oct 18 '22 03:10

Oliver Trampleasure