In my Rails 4 application I have a Task model which can be sorted/grouped in various ways (group by user, group by transaction, order by due date, etc...) on several different pages. Users can create a new Task via an AJAX popup modal from anywhere in the application, and if they are on a page that is displaying a list of tasks, I would like to append the task to the list if appropriate.
# NOTE: tasks should only ever be displayed one of the ways below, not multiple
# Displaying tasks grouped by user
<div id="user-1-tasks">
<div class="task">...</div>
...
</div>
<div id="user-2-tasks">
<div class="task">...</div>
...
</div>
# Displaying tasks grouped by transaction
<div id="transaction-1-tasks">
<div class="task">...</div>
...
</div>
<div id="transaction-2-tasks">
<div class="task">...</div>
...
</div>
# Displaying all tasks together (ordered by due date)
<div id="tasks">
<div class="task">...</div>
...
</div>
The logic for determining which proper list to update is complex, because there may not be any list on the page (no update) or it could be sorted in several different ways like the examples I list above. It all depends on what page the user was on when they created the Task and how they had their tasks sorted (if any).
I came up with a "hack", whereby I pass all the possible DOM IDs that can be updated in a specific order and it updates the proper one on the page, or none if there aren't any.
Calculating what page the user is currently viewing and how the list is sorted is complicated, so it seemed much simpler to tell the JS to update "all" of the lists, knowing that only 1 should be present on the DOM at a time. The order of the DOM IDs is important, using the most-specific first and the fallback option last.
Note I am using server-generated JS via Rails' js.erb. I'm aware of the discussion surrounding this practice, but for this application it was much cleaner to maintain 1 copy of my HTML templates on the server instead of trying to do it all client-side and pass JSON back and forth.
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :user
belongs_to :transaction
def domids_to_update
"#transaction-#{transaction.id}-tasks, #user-#{user.id}-tasks, #tasks"
end
end
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
# Only one of several pages that can display tasks a variety of ways
def index
if params[:sort] == "by-transaction"
#tasks = Task.includes(:transaction).group_by(&:transaction_record)
elsif params[:sort] == "by-user"
#tasks = Task.includes(:user).group_by(&:user)
else # default is order by due date
#tasks = Task.order(:due_date)
end
end
def create
#task = Task.create(task_params.merge(user: current_user))
# create.js.erb
end
private
def task_params
params.require(:task).permit(
:title,
:transaction_id,
:due_date
)
end
end
# app/views/tasks/create.js.erb
$("<%= #task.domids_to_update %>").append("<%=j render(partial: 'tasks/task', locals: { task: #task } %>");
As crazy as it seems, this actually works, because there "should" only be 1 of the lists present on the page at a time.
However, when I do things that seem crazy, it's usually because they are.
Is there a way to tell jQuery (or even with plan JavaScript) to only update the first occurrence of the #task.domids_to_update? The returned IDs are in a specific order, with the most specific first and the catch-all at the end.
Or is there a better way to implement the functionality I'm trying to achieve?
I'm slowly trying to remove my jQuery dependency, so my preference is vanilla JavaScript solutions. I only need to support modern browsers, so I don't have to worry about IE6, etc.
Or if the solution is resolved server-side by calculating the appropriate list ID to update I'm open to that as well.
You could use plain js to check for element existence and assign the first existing element to a variable:
# app/models/task.rb
# first, in your assignment, lose the spaces and the hashes,
# to make it simple to convert to array:
def domids_to_update
"transaction-#{transaction.id}-tasks,user-#{user.id}-tasks,tasks"
end
# app/controllers/tasks_controller.rb
var ids = "<%= #task.domids_to_update %>".split(',')
var theElement = document.getElementById(ids[0]) || document.getElementById(ids[1]) || document.getElementById(ids[2])
if (theElement !== null)
jQuery(theElement).append(...)
// or with plain js:
// var html = theElement.innerHTML
// html = html + "appended html"
// theElement.innerHTML = html
end
Related
In Rails 6, I want the profiles/_form to have two dropdown lists, country and city. When I pick a value from country this is supposed to change the city choices. I want this to happen without
refreshing the page. My solution is below, and it kind of works for the new action, but it doesn't work for the edit action. Is this the right approach or am I totally missing the idiomatic rails 6 solution?
A route to return the option tags for the city select box:
# config/routes.rb
get 'cities_by_country/:id', to: 'profiles#cities_by_country'
The action that runs
# profiles_controller
def cities_by_country
#city_list = Profile::CITY_LIST[params[:id].to_i]
respond_to do |format|
format.js { render :cities_by_country}
end
end
The js file to generate the option tags
#views/profiles/cities_by_country.js.erb
<%= options_for_select(#city_list) %>
The javascript to attach the "change" event on the country select tag:
# app/javascript/packs/country_cities.js
import Rails from '#rails/ujs';
var country_select, city_select, selected_country;
window.addEventListener("load", () => {
country_select = document.querySelector("select#profile_country");
city_select = document.querySelector("select#profile_city");
country_select.addEventListener('change', (event) => {
selected_country = country_select.selectedIndex;
Rails.ajax({
url: "/cities_by_country/" + selected_country,
type: "get",
data: "",
success: function (data) {
city_select.innerHTML = data;
},
error: function (data) { }
})
})
});
Sorry to pile it on you but this is really broken.
Lets start with the controller/route. The idiomatic way to do this through a nested route - GET /countries/:country_id/cities. Nor should you really be shoehorning this into your Profile model / ProfilesController.
You can declare the route with:
get '/countries/:country_id/cities', to: 'countries/cities#index'
Or by using resources with a block:
resources :countries, only: [] do
resources :cities, only: [:index], module: :countries
end
And the controller like so:
module Countries
class CitiesController < ApplicationController
# GET /countries/:country_id/cities
def index
#cities = Profile::CITY_LIST[params[:city_id].to_i]
end
end
end
Not sure I can really get behind why you would want to use a constant in a model that should not be responsible for this at all instead of actually creating Country and City models.
The biggest issue with your JavaScript is that its completely non-idempotent. It all runs on window.addEventListener("load") so that it works on the intitial page load and then breaks down completely when Turbolinks replaces the page contents with AJAX since those event handlers were attached directly to the elements themselves.
To write JavaScript that works with Turbolinks you need to think differently. Create idempotent handlers that catch the event as it bubbles up the DOM.
# app/javascript/packs/country_cities.js
import Rails from '#rails/ujs';
document.addEventListener('change', (event) => {
let input = event.target;
if (input.matches('#profile_country')) {
Rails.ajax({
url: `/cities/${input.value}/country`,
type: 'get',
dataType : 'script'
});
}
});
If you want to use a js.erb template you also need to rewrite your view so that it transforms the page:
// app/views/countries/cities.js.erb
document.getElementById("#profile_city").innerHTML = "<%= j options_for_select(#cities) %>";
But you could also just use JSON and create the option elements on the client if you want to avoid making your server responsible for client side transformations.
i have a webpage where user can select one of different packages to buy from a list. Package details are coming from a database.
HTML Code
<div data-package='2346343' class="retail-package">Cost : 10$</div>
<div data-package='5465654' class="retail-package">Cost : 20$</div>
<div data-package='3455675' class="retail-package">Cost : 30$</div>
Jquery Code
$('.retail-package').on('click', function() {
$(this).addClass("selected-package");
var selectedPackage = $(this).data("package");
});
Now above code shows how we(specially i) normally select a particular thing out of a list when clicked, In this procedure, as you can see in HTML Code, i am giving out or showing the pakcageId to users i.e. anyone can do a inspect element in a browser and view or even manipulate the data-package attribute, for safety i do a server side check of selected data.
My Question
Is there a way to hide this data exposure, or is there any other cleaner way to accomplish this, because i have seen people using Angular, Webpack etc able to implement a list selection without giving out or showing any data which can be seen by inspect element feature in a browser.
Note : i am sorry if my question is too basic, if this cannot done using jquery what are other technologies which i can use ?
You may create a Map where keys are arbitrary, auto-incremented identifiers, and values are package numbers:
const idPackageMap = new Map()
// id generator: whenever you call it, "i" is incremented and returned
const id = (() => {
let i = 0
return () => ++i
})()
const addPackage = package =>
idPackageMap.set(id(), package)
addPackage(2346343)
addPackage(5465654)
addPackage(3455675)
console.log('contents: ', [...idPackageMap.entries()])
console.log('package number for id 2: ', idPackageMap.get(2))
Now, when you insert those <div> elements you may set the arbitrary identifier, and when you need to locate the actual package number is just about using Map#get: idPackageMap.get(1) (change 1 with any arbitrary identifier).
I need to do some processing on the background after the user has submitted a request. After the job is done, i want to show a message to user that your job is done.
So far i have this
lib/jobs/custom_job.rb
class CustomJob
def perform
sleep 1 # Here will do the background processing.
ActionView::Base.new('app/views', {}, ActionController::Base.new).render(file: 'teacher/courses/custom')
end
end
custom.js
$(document).ready(function() {
alert("Your job is done.");
});
controller
class Teacher::CoursesController < ApplicationController
def index
Delayed::Job.enqueue(CustomJob.new)
#courses = current_teacher.courses.all
end
end
Worker start a job and finished it with no error.
[Worker(host:Aragorn pid:3360)] Job CustomJob (id=16) COMPLETED after 1.0635
[Worker(host:Aragorn pid:3360)] 1 jobs processed at 0.9035 j/s, 0 failed
Thanks in advance.
You will need to have some sort of polling interface on the front end looking for a complete event on the object on the back end or use some sort of service like pusher.com.
We do this often and this is one of the better patterns we have found which will allow for the user to reload / leave the page and come back to the notice still available if it is still pending completion.
Possible example to check out
I'm writing cucumber feature specs using capybara, with poltergeist as the Javascript driver.
In one scenario, a step asks capybara to fill in a number field:
When /^I set the group count to (\d+)$/ do |group_count|
within 'form#new_practitioner_activity' do
find('input#number_of_groups').set group_count
end
end
For some reason, at that step a request gets generated (I've used Poltergeist's page.driver.network_traffic object to check it out) and the whole scenario is derailed as the request is redirected to the welcome page.
Changing the value of the field does trigger some Javascript (that's the point of writing the scenario to test it), but when I run in Chrome and watch the network panel, I don't see any requests there, and the JS code isn't intended to make any calls back to the server. (The script picks up a nested form, clones it, and appends it, if that matters.)
Here's the Coffeescript relevant to the field change. I'm eliding a good bit of each of these classes to try to clarify it.
class GroupCountElement
constructor: (#controller, #form) ->
# On instantiation we set up instance attributes for a div, and an input
# contained in it
#input = #form.find('.input.group-count')
#inputField = #input.find('input#number_of_groups')
#form.on('change', 'input#number_of_groups', #countChanged)
countChanged: =>
#form.trigger('groupcount:changed')
val: (newValue) =>
# This method just lets us treat the object like an input element
if newValue?
#inputField.val(newValue)
else
#inputField.val()
Here's what #form does with that groupcount:changed event:
class Form
constructor: (#controller, #form, #academicYearId) ->
# ... other setup stuff...
#groupInputs = []
#form.find('.group-activity').each (i, row) =>
#groupInputs.push(new GroupActivity(#controller, #form, row))
#groupCount = new GroupCountElement(#controller, #form)
#form.on('groupcount:changed', #groupCountChanged)
groupCountChanged: (e) =>
delta = #groupCount.val() - #groupInputs.length
if delta > 0
#groupInputs.push(new GroupActivity(#controller, #form)) for i in [1..Math.min(delta, 10-#groupInputs.length)]
else if delta < 0
#groupInputs.pop().delete() for i in [Math.max(delta, 1-#groupInputs.length)..-1] unless #groupInputs.length is 1
Then this is the GroupActivity class which is having instances of itself pushed and popped on the Form:
class GroupActivity
constructor: (#controller, #form, #groupForm) ->
if #groupForm?
# This means we're creating an instance reflecting DOM already in the form
#$groupForm = $(#groupForm)
else
# This means we're cloning an existing chunk of DOM - a row - and adding
# it to the form
#$groupForm = #form.find('.group-activity').last().clone()
# Because the nested form elements we just cloned have names
# and IDs with an array index, our new row needs to have those
# attributes updated with the index +1. #incrementFormAttrs()
# handles that.
#incrementFormAttrs()
#append() # Sticks the cloned DOM into the form
#reset() # Clears any values in the cloned DOM
incrementFormAttrs: =>
oldIndex = #$groupForm.find('.input.instructional-focus label').attr('for').match(/practitioner_activity_group_activities_attributes_(\d+)/)[1]
newIndex = parseInt(oldIndex, 10) + 1
#$groupForm.find('label').attr 'for', (i, val) ->
val.replace(/practitioner_activity_group_activities_attributes_(\d+)/, "practitioner_activity_group_activities_attributes_#{newIndex}")
#$groupForm.find('select, input').attr
id: (i, val) -> val.replace(/practitioner_activity_group_activities_attributes_(\d+)/, "practitioner_activity_group_activities_attributes_#{newIndex}")
name: (i, val) -> val.replace(/practitioner_activity\[group_activities_attributes\]\[(\d+)\]/, "practitioner_activity[group_activities_attributes][#{newIndex}]")
append: =>
#$groupForm.insertBefore( #form.find('.actions') )
reset: =>
if #$groupForm
new Deselector(#$groupForm.find('select'))
#$groupForm.find('input').val(undefined)
delete: =>
#$groupForm.remove()
So, to trace it out: the test changes the value of the #inputField in the GroupCountElement. That sends a groupcount:changed event to the Form, which calls its groupCountChanged() method. In this case the number is most likely to go UP, so we're adding at least one GroupActivity instance, which in its constructor will call incrementFormAttrs(), append(), and reset().
None of these should trigger a request as I understand them. What could be kicking off this extra request which is breaking my test?
I have been looking for a way to allow the user to easily change the order of entries in a formset. I found a StackOverflow question that addresses this subject, with the accepted answer referencing a Django Snippet that uses a JQuery tool to allow drag 'n drop of the entries. This is nifty and cool, but I have a problem with 'extra' entries. If an 'extra' entry is modified and then dragged I get an error on the submit:
(1048, "Column 'order' cannot be null")
I believe Django keeps the 'extra' entries separate, since they have to be inserted instead of updates. So the reordering probably confuses matters. Is there a way to make this work, or are there other suggestions for reordering and/or adding new entries?
Edit: Added some relevant code excerpts. I'm trying this out in the admin, as the snippet shows. I'd like to put it in my own page ultimately, however.
models.py:
class Section(models.Model):
section_id = models.AutoField(primary_key=True)
section_name = models.CharField(max_length=135)
score_order = models.IntegerField()
def __unicode__(self):
return self.section_name
class Meta:
db_table = u'section'
ordering = [u"score_order"]
class Chair(models.Model):
chair_id = models.AutoField(primary_key=True)
member = models.ForeignKey(Member, null=True, blank=True,
limit_choices_to={'current_member': True})
section = models.ForeignKey(Section)
description = models.CharField(max_length=135)
order = models.IntegerField(blank=True, null=True)
def __unicode__(self):
return "%s - %s" % (self.description, self.member)
class Meta:
db_table = u'chair'
ordering = (u'section', u'order')
admin.py
class SectionForm(forms.ModelForm):
model = Section
class Media:
js = (
'/scripts/jquery.js',
'/scripts/ui.core.js',
'/scripts/ui.sortable.js',
'/scripts/section-sort.js',
)
class ChairInline(admin.StackedInline):
model = Chair
admin.site.register(Section,
inlines = [ChairInline],
form = SectionForm,
)
I found my own solution. The snippet was setting the order for every row that had a non-empty primary key value. But the extra rows have an empty primary key, and I believe they have to stay empty for Django to know that they are to be inserted instead of updated. I modified the function to check for the other fields being empty (fortunately, I only have a couple) as well as the primary key:
jQuery(function($) {
$('div.inline-group').sortable({
items: 'div.inline-related',
handle: 'h3:first',
update: function() {
$(this).find('div.inline-related').each(function(i) {
if ($(this).find('input[id$=chair_id]').val() ||
$(this).find('select[id$=member]').val() ||
$(this).find('select[id$=description]').val()) {
$(this).find('input[id$=order]').val(i+1);
}
});
}
});
$('div.inline-related h3').css('cursor', 'move');
$('div.inline-related').find('input[id$=order]').parent('div').hide();
});
This did the trick for me. I could maybe improve it by adding a hidden field to the form that gets set whenever any field on a row gets modified. But at this point I'm still a jQuery n00b, so this will do. If anybody has better ideas, feel free to comment or add another answer.
I do sortable formsets in one of my apps. I use this jQuery drag and drop plugin:
http://www.isocra.com/2008/02/table-drag-and-drop-jquery-plugin/
I bind the plugin's onDrop event to a function that resets the value of all "order" fields.
Additionally, I pass initial data to the formset so the formset's extra "order" fields always have a value in cases where there is no javascript available - the user won't be able to re-order rows, but they can edit and post changes without the null error you described.