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:
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..."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
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 objectinput_html: { data: { autocomplete: @model_json } }
- this adds a custom attribute with all your JSON data, this is parse byYou 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 divplaceholder="..."
this text is used as a placeholder once the chips is initialised, this can be left blank / not includedname="setting[team_ids]"
helps simple_form understand which model this applies toclass="chips"
ensures that our javascript later knows to initialise chips
on this elementdata-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.
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 %>
$(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>
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With