Sort Account-Level Course Page Terms in Descending Order

dgrobani
Community Champion
21
4359

The terms list on the account-level course page is sorted in ascending order. When I filter terms, I almost always want the current term or a recent term. Having to scroll to the bottom of our long list of terms is annoying, so I wrote some JavaScript (with the help of Stack Overflow) to sort the list by term ID in descending order. In case anyone else might find this useful, here it is:

if (window.location.pathname.indexOf('accounts') > -1) {
    // https://stackoverflow.com/questions/20693593
    var selectOptions = $('select optgroup[label="Active Terms"] option');
    selectOptions.sort(function(a,b){
        return parseInt(b.value) > parseInt(a.value);
    });
    $('select optgroup[label="Active Terms"]').html(selectOptions);
}‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍

(I welcome tips for improvement, carroll-ccsd  @James ‌!)

Tags (2)
21 Comments
robotcars
Community Champion

Hi Daniel,

I think you tagged me in this originally and it landed in my inbox, so I went through a couple hours of testing. Even tried to get rid of jQuery. I was able to replace the first 2 lines with plain old vanilla JavaScript, gave up getting the elements back into the DOM, jQuery's html() method is so nice and easy.

First,

window.location.pathname.indexOf('accounts') > -1 will be true, for any page with accounts in the title, and the code in the block will fire uncessarily. ex: /courses/123/pages/accounts-test

Try to be very specific, especially where users can throw a wrench into the logic.

I've provided an alternative below, including improvements by  @James 

via https://community.canvaslms.com/message/112296-re-custom-javascript-for-admincourses-page 

It ensures /accounts is the root ^ of the pathname, and prevents further matching when the pattern is matched $

Second,

I tried your snippet in Chrome and Firefox, and could not get the sorting to work in Chrome. It worked fine in Firefox. Can you confirm this?

I will share this, which works in Chrome, Firefox, and Safari... and uses a little ES6. Arrow functions - JavaScript | MDN

// needs be remain on 1 line
selectOptions.sort((a,b) => b.value - a.value)‍‍

// and is equivelent to
selectOptions.sort((a,b) => {
    return b.value - a.value
});

// and also
selectOptions.sort(function(a,b) {
    return b.value - a.value
});

IMO, if you can do it on 1 line, do it in 1 line. Smiley Happy

Additionally, I tried to remove duplicate/longer user defined values, by reusing the selector parts and your variable selectOptions. Since JavaScript is downloaded at the bottom of the page/generally last for users, I try to make the files/byte size/character length as short and sweet as possible. Personally, compress our JavaScript files down with minification before we put them on Canvas, this changes things like var myCamelVar = 1; into var a=1;

Choose for yourself, also if this is for a userscript (w/ Tampermonkey), it doesn't matter.

All together...

(function() {
     'use strict';
     if (/^\/accounts\/\d+$/.test(window.location.pathname)) {
          let grp = 'select optgroup[label="Active Terms"]',
               opts = $(grp +' option');
          opts.sort((a,b) => b.value - a.value);
          $(grp).html(opts);
     }
})();‍‍‍‍‍‍‍‍‍

That's about 100 characters/~bytes smaller... (excluding your stack //comment)

...before adding IIFE - MDN Web Docs Glossary: Definitions of Web-related terms | MDN.

The IIFE is important. Providing 2 features: (1) It blocks your code from the global scope, this means grp and opts don't have to be unique to Canvas' JavaScript variables, or any other code blocks you are using where you might use grp/opts or selectOptions. Preventing unwanted interference. (2) It's a function expression, making it immediately invoked upon execution.

robotcars
Community Champion

Remembered a couple more thoughts, and I know if I edit that post it's going for moderation, this is easier. :smileygrin:

There's a lot of React attributes in that select, suggesting it is populated by React. I saw 1 instance where the select was empty on page load. It might be necessary to add MutationObserver - Web APIs | MDN, which for /accounts can be taken from Custom JavaScript for Admin/Courses Page. I added the snippet above to a Tampermonkey user script, and it seemed to work fine without a mutation observer. YMMV

Sorting b.value and a.value, I removed the parseInt, see Long ID's in Javascript

It doesn't cause an issue here for sorting, and it allows the code to be more portable.

dgrobani
Community Champion
Author

Robert, thanks so much for taking the time to review my code, offering your improvements, and providing such a clear and thorough explanation of your reasoning. I learned a lot and really appreciate you for sharing your wisdom and experience!

robotcars
Community Champion

Anytime Daniel. Thanks for the exercise! I had no idea 8 lines of code was going to send me down a rabbit hole of can I do this, or that, how about this.

James
Community Champion

The parseInt() may actually be necessary, though. He's treating it it as an indicator of when the term was created. A bigger termID means a newer term. But if one term was 9852 and another was 10245 and you're treating them without the parseInt(), then the 10245 would not come after the 9852.

Edit: I need more sleep.

Both of you go for simple code, but I wouldn't allow myself to use the .html(). Because I like to do things the hard way, so I would rewrite it with insertBefore() or appendChild(). It does make it harder to write, but I don't have to worry about any event listeners or mutation observers that might have been added to the content of the list that would be lost by doing an .html() replacement. That said, putting event listeners or mutation observers on an item inside a list is probably not the right way to do things, so the chances of it affecting something is reduced.

dgrobani
Community Champion
Author

James, you're right--my comparison test required parseInt() to work. Robert's subtraction test coerces the strings to integers, so parseInt() isn't necessary.

robotcars
Community Champion

I couldn't find any documentation to support why it works...

Thoughts?

var ok = ['123','789','456','8916','112','1111']

ok.sort()
//(6) ["1111", "112", "123", "456", "789", "8916"]

ok.sort((a,b) => parseInt(b) - parseInt(a))
// (6) ["8916", "1111", "789", "456", "123", "112"]

ok.sort((a,b) => b - a)
// (6) ["8916", "1111", "789", "456", "123", "112"]
James
Community Champion

My bad. I shouldn't try to look at stuff without sleep. I saw you said you took out the parseInt() without noticing that you subtracted values instead.

The reason it works is that subtraction forces type coercion. Since it doesn't know how to do subtraction of strings, it forces them into something that it does know how to subtract -- numbers. parseInt() is more explicit, but I don't think that  the implicit coercion would work in the case of long JS IDs any more than parseInt() would.

I did a quick test

(function() {
    'use strict';
    let s = '';
    for (let i = 1; i <= 20; i++) {
        s = s + (i % 10);
        const t = s - 1;
        console.log(s + '\t' + t + '\t' + (s - t));
    }
})();‍‍‍‍‍‍‍‍‍

s and t act as expected until up until 16 digits, until they pass the MAX_SAFE_INTEGER value.

1 0 1
12 11 1
123 122 1
1234 1233 1
12345 12344 1
123456 123455 1
1234567 1234566 1
12345678 12345677 1
123456789 123456788 1
1234567890 1234567889 1
12345678901 12345678900 1
123456789012 123456789011 1
1234567890123 1234567890122 1
12345678901234 12345678901233 1
123456789012345 123456789012344 1
1234567890123456 1234567890123455 1

9007199254740991 MAX_SAFE_INTEGER
12345678901234567 12345678901234568 0
123456789012345678 123456789012345680 0
1234567890123456789 1234567890123456800 0
12345678901234567890 12345678901234567000 0

The behavior is identical if I use const u = parseInt(s); and t = u - 1;

In either words, neither one seems to handle the long ID issue. However, in the case of Long IDs, they are going to be 0 padded in front of the term and a straight string comparison would work without conversion to integers.

robotcars
Community Champion

For some due diligence, I checked into whether we can determine if LONG ID's are an issue here and will the code be unpredictable for some users?

Developer Tools / Network Tab, reveals...

The select is populated* by an API call to Enrollment Terms - Canvas LMS REST API Documentation

  • I see short ids for the terms here
  • ours is populated in step, from 5 requests per_page=10
  • the values in the select are stored while searching, so there should only be a need for 1 sort operation per page load

/wishes Canvas would figure out the relative size of our instance and set pagination defaults to 50 or 100

Found this account_course_user_search/components/CoursesToolbar.js at master · instructure/canvas-lms · GitHub 

Asked the IRC channel, if we should ever expect LONG IDs in that field, they said

the API will return relative IDs if called from the same account, and would be the local [short] id

Therefore, long ID's should never be an issue here.

So, my comment above about parseInt, Sorting, Long IDs, and portable code is moot.

* assert mutation warnings, especially if you're piling on things like Custom JavaScript for Admin/Courses Page

James
Community Champion

Knowing that long IDs is not a problem when called from the same account solves a lot of potential issues and makes a rewrite of a bunch of code not necessary, so thanks for checking that with the engineers. I'm not completely convinced it was always that way based on what other people have said about changes that impacted a few number of organizations, but hopefully going forward it means we won't have to worry.

As for the Canvas per_page=50 or per_page=100, I think I've seen that where thought is put into it, it's to get a quick response. That is, it seems to take me longer with a per_page=100 to get that initial load, although in the long run, the load time is reduced because of fewer network calls. I don't have documentation available (at least that I can remember where I put it), but it seems that even when there was less than 100 items, it still took longer with a per_page=100 on it. I know that doesn't make sense, but I don't think I'm imagining it (they may have fixed it). At one time, when they loaded the gradebook data, it was (I think) per_page=33 to get a balance between initial speed and load time. However, if they're using the default of 10, then it probably hasn't been studied.

robotcars
Community Champion

It's not that I like simple code, it's that I prefer simple to maintain code. Which may not always be the easiest code, and often takes me longer to figure out.

My first response was provided because I'm starting to run out of time to tinker with these things in the evening, but JS is a lot of fun. Without jQuery there are probably a dozen or more ways to go about resorting these options. Without ES6 most of those require for loops and a lot of other syntax that adds a lot of lines, characters and ultimately maintenance to a file. I'm trying to avoid things like W3 | How To Sort a List being the solution for something this simple. As I replace jQuery in our code base I'm working on learning and exploring the new ways JS can do this. 

I didn't really give up, I just wasn't sure how pressing the solution was to dgrobani, so I gave what I had, and counted on discussion. I wanted to do everything with ES6 features, but I was having a lot of difficulty putting it all together. I spent about 6-7 hours on this as time permitted. Since sorting cannot be performed on the option objects/elements themselves, they have to be sorted as objects in an array, then I sort the objects in the array by the objects property value. Then update the DOM.

This works in the console

For full effect, it probably needs a mutation observer since the select is populated by React.

(function() {
     'use strict';
     if (/^\/accounts\/\d+$/.test(window.location.pathname)) {
          let sel = 'select optgroup[label="Active Terms"]',
               grp = document.querySelector(sel),
               opts = [].slice.call(document.querySelectorAll(`${sel} option`));
          opts.sort((a, b) => b.value - a.value);
          opts.reverse(); // disable for ascending order
          Object.keys(opts).forEach(o => grp.insertBefore(opts[o], grp.firstChild));
     }
})();‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍
robotcars
Community Champion

 @James 

I know that you're using jQuery tablesorter for the Course Roster Enhancements. I played around the snippet and made the roster sort by the total activity column, sans jQuery. Not that this is hot swappable in your code, but... these are the kinds of things I'd like to simplify before abandoning jQuery.

// sort course roster by total activity column
(function() {
     'use strict';
     if (/^\/courses\/\d+\/users$/.test(window.location.pathname)) {
          let sel = 'table.roster tbody',
               tbody = document.querySelector(sel),
               rows = [].slice.call(document.querySelectorAll(`${sel} td:nth-child(8) div`));
          // https://stackoverflow.com/a/45292588
          const sTs = (acc,time) => (60 * acc) + +time;
          rows.sort((a, b) => {
               var aa = a.textContent.split(':').reduce(sTs),
                    bb = b.textContent.split(':').reduce(sTs);
               return bb - aa;
          });
          rows.reverse(); // disable for ascending order
          Object.keys(rows).forEach(i => tbody.insertBefore(rows[i].parentNode.parentNode, tbody.firstChild));
     }
})();‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍
dgrobani
Community Champion
Author

carroll-ccsd, before I posted my code, I was certain I'd made sure it worked other than just in the console. But if it ever did, it doesn't now! As you said, the targeted elements are created by React. I've been plunking around in spare moments the last few days trying to find a good strategy and an element to target for onElementRendered() or a mutation observer, but a new issue I'm encountering is that it's quite possible to click the terms dropdown before the list is fully populated, resulting in a partially sorted list. So the search continues....

This is meant to continue the conversation with whoever might be still paying attention, not to ask you to commit any more of your time to this. Thanks again!

robotcars
Community Champion

I also hate the Observer Effect in physics...

I experienced that too in the last 24 hours. The code I submitted prior is no longer working in a user script and the code I submitted last night only works within a mutation observer. I actually tested it along side ccsd-canvas/admin-course-links and it works nicely. Try stealing the Mutation Observer from there and see if you make some progress. I will try again when I get some time.

Note that within a mutation observer you will need to flag some kind of 'completed' status, so it doesn't attempt to sort the list for every mutation. The list never gets updated after the initial load, even when searching and using pagination.

James
Community Champion

I totally understand the time issues. I've been so busy with work, I'm only getting to log into the Community every couple of days. Almost everything I've written is in maintenance mode and I'm doing very little new development on anything until a couple of big projects get done at work. When I do get on, it's usually after a long day of teaching and my mind isn't fully functioning. Sorry if "simple code" in a negative manner -- I meant it as fewer lines. I don't always use the most efficient functions since I don't understand them. I personally try to avoid the .html() method now, although in some of my early stuff, I would have certainly used it and probably with jQuery (my things that added buttons were done that way). Now I spend 8 lines to do what might only need 1 if I would just allow myself to use the .html(). That leads to complex (not simple) code. I don't have time to go back and rewrite things unless something majorly breaks and I am forced to. When QuizWiz broke with the 2018-09-15 release, it turned out to be a single change to a selector to get it back, so I made that and moved on. I even did something I don't normally do and programmed a fall-back so that people who were self-hosted and hadn't upgraded yet would not have it break for them.

The tablesorter was something I looked for a non-jQuery version for, but couldn't quickly find something that did everything I needed. I'm not opposed to using libraries at all costs -- they're written to make life easier and I've started using a bunch with some Node stuff I'm writing. In the browser, I'd like to have self-contained code if possible, or only use libraries that other people already make available (like jQuery). As a user script, it's not such a big deal since you can include all those in the script and it download it once.

One of my original hesitations in using external libraries was in attempting to write things that could be used in the global JavaScript. I didn't want to force people into using the library I picked just to get my script to work when they already were using something else that had a similar feature.

As for the code you put out there, I would have to study it. I rarely use map() and never use reduce() {I'm not opposed, just haven't learned it yet}. On the other hand, Tablesorter allows me to write custom parsers for any of the fields and use the same code rather than having to write a sort routine for each of them. It also allows a tri-sort of ascending, descending, or original order. In other words, there would be a lot of rewriting of the code to accomplish the same thing that a library accomplishes. If we lose jQuery completely, then I would definitely have to look at other options, although I might start adding it to the user script.

robotcars
Community Champion

Here's the shortest I could make it. Observer listens on the #content container and checks if the optgroup exists and if there's a reason to sort. Sorts and disconnects the observer. I was able to run it side by side with another user script.

(function() {
    'use strict';
    if (/^\/accounts\/\d+$/.test(window.location.pathname)) {
        const sort_opts = (mtx, obs) => {
            let sel = 'select optgroup[label="Active Terms"]',
                grp = document.querySelector(sel);
            if (typeof grp == 'object' && grp.children.length >= 2) {
                let opts = [].slice.call(document.querySelectorAll(`${sel} option`))
                    .sort((a, b) => b.value - a.value)
                    .reverse() // disable for ascending order
                Object.keys(opts).forEach(o => grp.insertBefore(opts[o], grp.firstChild));
                obs.disconnect();
            }
        };
        const watch = document.getElementById('content');
        const observer = new MutationObserver(sort_opts);
        observer.observe(watch, { childList: true, subtree: true });
    }
})();‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍

In case it's used in conjunction with any other mutation observer on #content, this version tags the optgroup with a class after sorting to prevent secondary sorting instead of using the observer disconnect. The observer callback could be a tertiary function, and fire sort_opts() and other callbacks. I tested this with the course links script.

(function() {
    'use strict';
    if (/^\/accounts\/\d+$/.test(window.location.pathname)) {
        const sort_opts = () => {
            let sel = 'select optgroup[label="Active Terms"]',
                tag = 'rc-sort-options',
                grp = document.querySelector(sel),
                tagged = document.querySelectorAll(`${sel}.${tag}`);
            if (tagged.length < 1) {
                let opts = [].slice.call(document.querySelectorAll(`${sel} option`));
                opts.sort((a, b) => b.value - a.value);
                opts.reverse(); // disable for ascending order
                Object.keys(opts).forEach(o => grp.insertBefore(opts[o], grp.firstChild));
                grp.classList.add(tag);
                //obs.disconnect();
            }
        };
        const watch = document.getElementById('content');
        const observer = new MutationObserver(sort_opts);
        observer.observe(watch, { childList: true, subtree: true });
    }
})();‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍

I can tell I've picked up few tricks on mutations from  @James 

My new challenge to pass it the other optgroup labels as parameters and sort each group.

robotcars
Community Champion

You've contributed a lot good with the code you have in maintenance mode right now. I think it's nice that you've solved some pain points for users and that you share what you've learned. I spend a day coding at work, but still enjoy some tinkering when we sit down for the evening. I've had similar experiences with code breaking and the small fixes. We've also deprecated code that has been rendered unnecessary by changed functionality. The impermanent nature of our code against changes in Canvas often leaves me weighing what's easier to maintain vs what it might be, if it was the only project. JavaScript aside, the rest of our code is either automated tasks pushing and pulling data to and from Canvas and reports and weekly tasks. The daily tasks get code with the intention of 99% success, usually internet connectivity, maintenance, and sometimes breaking changes in the stack to and from Canvas. Reports and other tasks get improved weekly or as used. The usage of libraries is a huge benefit. I use your Canvas Data api because it helped me bootstrap a nightly process and supports an LTI, and it's typically flawless when there is internet. Recently I was getting 0 byte files half way through the process and as I started working to add code to fallback and retry missing files I moved the task to 6PM instead of 9PM and it no longer has the issue. File that one for another day. You mention Node, and we've been working with Ruby and Node lately. Compared with PHP, their dependencies make it so much easier than writing everything from scratch.

I like tablesorters versatility too, and we still have a pretty heavy use of jQuery in our code, especially for the simple AJAX functionality. I'm hesitant to include external libraries in any scripts because I'm not sure what other parties might have in global javascript. The trend I see is copy and pasting snippets on top of each other, often without IIFE's or pathname tests. As I start moving away from jQuery, I'm not so much trying to create complex code as I want reduce verbose code. I like to have smaller file sizes for the global javascript. It's one of the last things the user downloads and not everyone has broadband. Unlike a user script, most of the features are downloaded by all users. Many of new JS features and functions are hard for me to grasp too. I often have trouble doing anything past the MDN example or finding the use case I'm attempting to utilize. Since some of our JS code is reused multiple places in the file, it's handy to have functions and callbacks that can be reused. Shorter syntax helps too. This little sort has been an opportunity to experiment with those alternatives. I like the adventure of JS projects.

I really just like learning, sharing, and the discussion here. The community adds some randomness to when we happen to revisit these topics, and find ourselves defining things like 'simple code'.

Here are some of the steps I took for this topic.

var grp = document.querySelector('select optgroup[label="Active Terms"]');
var opts = document.querySelectorAll('select optgroup[label="Active Terms"] option');

// -- array of options

//var list = [];
//for(let i = 0; i< opts.length; i++){
//     list.push(opts[i]);
//}
//[].slice.call(opts).forEach(i => { list.push(i) });
//var list = Array.prototype.map.call(opts, function(opt) {
//   return opt;
//});
var list = Array.prototype.map.call(opts, opt => opt);

// -- sort

list.sort((a, b) => b.value - a.value);
list.reverse();

//-- update position

//for(var i = 0; i<list.length; i++){
//     grp.insertBefore(list[i], grp.firstChild);
//}
//Object.keys(list).forEach(function(o) {
//     grp.insertBefore(list[o], grp.firstChild)
//});
Object.keys(list).forEach(o => grp.insertBefore(list[o], grp.firstChild));
‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍
canvas18
Community Member

Not that you need independent confirmation...but this works great for me, too!

robotcars
Community Champion

Mike, I had you in mind working on this, since we already have a script for the Course search. I believe the second example in comment Sep 27, 2018 7:01 AM should work best for you. I know you have to get code approved if it's not a user script, but if you're going to place this in Global JS, you can fire both call backs by wrapping them and changing the mutation callback function. This makes the file a smaller download. Here's the example I worked with.

After adding just the sort_opts() to the add links script...

- add the new callback fire_both
- change the line for the Mutation Observer

const fire_both = () => {
     sort_opts();
     courses();
}

const observer = new MutationObserver(fire_both);‍‍‍‍‍‍

Heres a gist with it all together, course links with term select sort · GitHub 

dgrobani
Community Champion
Author

A quick note of thanks! I got pulled away to More Important Things and haven't had a chance to catch up on this, but I will soon.

donna_lummis
Community Participant

Sorting terms in descending order so that the most active terms are listed first should be the default. I think I found this feature idea at https://community.canvaslms.com/ideas/1248-more-intuitive-term-sortingpresentation. Feel free to vote this idea up.