snipe / snipe-it

A free open source IT asset/license management system
https://snipeitapp.com
GNU Affero General Public License v3.0
10.88k stars 3.14k forks source link

Checkout Asset - User - Automatically select by Employee ID #5857

Closed takuy closed 6 years ago

takuy commented 6 years ago

Server (please complete the following information):

Is your feature request related to a problem? Please describe. It is difficult to use NFC/mag stripe scanners for users when checking out assets. We are in a University setting, so students and employees have distributed ID cards that contain their university ID number.

Describe the solution you'd like In the Checkout Asset page (https://.../snipe/hardware/.../checkout), it would be great if just entering what matches to the "employee number" and pressing "Enter" would select that user. This would allow you swipe or scan an employee or student's ID into the field. Most NFC or magstripes get the ID off the card and then do a carriage return....so "309820998\n". Currently, this just does nothing to the field.

This would be a way better option than having to look through a list of users with similar names.

Describe alternatives you've considered The current solution is to either type their name in, or type in their ID.

snipe commented 6 years ago

@tilldeeke I think your PR #5773 might have fixed this?

tilldeeke commented 6 years ago

@snipe I don't think this would be fixed by the PR, sadly 😕

The problem (I think) seems to be that if you directly press Enter after entering the employee number, the correct entry doesn't get selected, because the ajax call for the search result hasn't finished yet. And pressing Enter then closes the list, without selecting the correct user.

@takuy Perhaps there is a setting on your scanner to disable the carriage return? If you select the user dropdown, scan the ID card, wait for the correct result to appear and press Enter then, that should allow you to select the correct user 🤔

takuy commented 6 years ago

@tilldeeke The proprietary NFC reader we use can be programmed as such by the University's card admin, but magstrip scanners usually cannot (unless you're purchasing high-end programmable magstrips).

Would a workflow change like this be possible? ...if Enter is pressed (or a carriage return is input) with typed content, it ignores the ajax response and does a second ajax call, responding solely with the first result it receives based on the contents? This would also work great in our case, where we have users with unique last names; type their last name, press enter, they're the only user with that name - they're selected, done! No reason for intervention to select from a list at that point when you are expecting a single result (this is generally how I see combo boxes work - great for enterprise apps)

takuy commented 6 years ago

I took a stab at implementing this, see below. It "works" but would there be a better way to do this? Currently, the behavior in Snipe seems to rely on the standard behavior of Select2. This hooks onto the closing event and checks if the value in the select field is empty or not...if not, do an ajax based on the Select2's data attributes...self explanatory below. This fulfills the requirement of typing an employee ID, username, name partial, and clicking Enter. It automatically sets the first result (if there is one). Magstripe works, scanner works...

Any changes that you would recommend? Or am I going about this the wrong way?

jQuery(function($){

    $("#assigned_user_select").on('select2:closing', function (e) {
        var data = e.params;
        var id;
        if(data.args && data.args.originalSelect2Event) id = data.args.originalSelect2Event.data.id;
        if(!id) {
                var endpoint = $(this).data("endpoint");
                var select = $(this).data("select");
                var value = $('.select2-search__field')[0].value; 

                if(!value) return;

                var element = $(this);

                $.ajax({
                    url: baseUrl + 'api/v1/' + endpoint + '/selectlist?search='+value+'&page=1',
                    dataType: 'json',
                    headers: {
                        "X-Requested-With": 'XMLHttpRequest',
                        "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
                    },
                }).done(function(response) {
                    var first = response.items[1]; 
                    if(first && first.id) {

                        var option = new Option(first.text, first.id, true, true);
                        element.append(option).trigger('change');

                        element.trigger({
                            type: 'select2:select',
                            params: {
                                data: first
                            }
                        });
                    }
                });
        }
    });
});
takuy commented 6 years ago

@tilldeeke I saw you mentioned you were going to look into this in the other issue. If I generalize my approach, do you think it would be a good way to go about this? I can submit a PR when I do a bit more work on it.

tilldeeke commented 6 years ago

@takuy I like your approach! I tried using the result of the ongoing request, but that seems a lot more complicated and fragile that your idea. Great!

I'm guessing these lines prevent the field to select anything if I just open & close it, without searching anything?

var value = $('.select2-search__field')[0].value; 

if(!value) return;

I can submit a PR when I do a bit more work on it.

Sounds great to me! 😊

takuy commented 6 years ago

So, this became a little more complicated with #5839, I think. Now that "Clear Selection" is no longer the top element, it DOES highlight the first element in the list.

So, I've modified this a bit. I now have to catch select2's selecting element, to stop it from selecting an element from the list that does not match (at all) what was searched for. This is because of the recent "Clear Selection" change - it makes it always default to the first element, so it IS selecting "something". If the selected element doesn't match the text, it discards it, forces a close - which in turn, does the AJAX call to see if it'll match something. Typing something which never yields as a result (either native behavior or my modified behavior) has the same "select" behavior as before - nothing happens.

jQuery(function($) {
    function getSelect2Value(element) {

        // if the passed object is not a jquery object, assuming 'element' is a selector
        if (!(element instanceof jQuery)) element = $(element);

        var select = element.data("select2");

        // There's two different locations where the select2-generated input element can be. 
        searchElement = select.dropdown.$search || select.$container.find(".select2-search__field");

        var value = searchElement.val();
        return value;
    }

    $(".select2-hidden-accessible").on('select2:selecting', function (e) {
        var data = e.params.args.data;

        var element = $(this);
        var value = getSelect2Value(element);

        if(value.toLowerCase() && data.text.toLowerCase().indexOf(value) < 0) {
            e.preventDefault();

            element.select2('close');
        }
    });

    $(".select2-hidden-accessible").on('select2:closing', function (e) {
        var element = $(this);
        var value = getSelect2Value(element);

        if(value) {
            var endpoint = element.data("endpoint");
            $.ajax({
                url: baseUrl + 'api/v1/' + endpoint + '/selectlist?search='+value+'&page=1',
                dataType: 'json',
                headers: {
                    "X-Requested-With": 'XMLHttpRequest',
                    "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
                },
            }).done(function(response) {
                var first = response.items[0]; 
                if(first && first.id) {

                    var option = new Option(first.text, first.id, true, true);
                    element.append(option).trigger('change');

                    element.trigger({
                        type: 'select2:select',
                        params: {
                            data: first
                        }
                    });

                }
            });
        }

    });
});

Not sure if I'm ready for a PR yet, but would like some suggestions as to how to improve this. My code looks kind of ugly to me right now. :grimacing:

@tilldeeke @snipe

Edit:

I've been testing this against the Demo site mostly, pasting into my browser's dev console. Theoretically, this code can live in resources/assets/js/snipeit.js once I've gotten a chance to do a PR.

tilldeeke commented 6 years ago

Hm, I tried out your code on multiple pages and and notices a problem. When I type something into a select box without pressing enter, I can't close the dropdown. It just stays open, until I remove everything from the searchbar 🤔

Besides that I don't think your code looks ugly! 😊

takuy commented 6 years ago

Interesting. I tested that case out before and didn't have at problems. Will check again. Which pages were you testing on?

I've been targeting my testing to bulk checkout page since it has 4 select2s of different kinds.

On Sat, Jul 28, 2018, 7:58 AM Till Deeke notifications@github.com wrote:

Hm, I tried out your code on multiple pages and and notices a problem. When I type something into a select box without pressing enter, I can't close the dropdown. It just stays open, until I remove everything from the searchbar 🤔

Besides that I don't think your code looks ugly! 😊

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/snipe/snipe-it/issues/5857#issuecomment-408602335, or mute the thread https://github.com/notifications/unsubscribe-auth/ABjjd2jPZ_HsLB0hF2FUGU3itHkBI208ks5uLFH0gaJpZM4VRy96 .

takuy commented 6 years ago

@tilldeeke Oddly, I'm not seeing that behavior. If you type something (with my code or default behavior), you can click anywhere on the page to close the dropdown. Not being able to close with "Enter" when the dropdown menu is populated with "No Results" is the default behavior.

I did find an issue with duplicates in dealing with selects that allow for multiple entries, though. Code with resolution below

Edit: Just had another idea - currently, I force an AJAX call on ANY closing where there's a typed value. That's dumb! If you're selecting a value that already matches a possible result, why do that?

Instead, the selecting event, can validate that at least something matches the typed text. If it matches (ie, indexOf() says the string is SOMEWHERE in the selected item), it'll set a flag inside the event. This event gets passed along to all subsequent events, so we can read it later in the closing event.

Open to any comments. Will probably submit for PR on Monday if no issues comes up.

Edit 2: Realized another bug in my code (a part of it was doing literally nothing). Not sure if this behavior is desirable or not, but this will allow it to select automatically, the next not-already selected item. So, if you type macbook 3 times and press enter, it'll select 3 different macbooks rather than just closing. Furthermore, I realized all of this modified behavior should only really happen when pressing Enter. Clicking is a lot more precise of a choice.

There was also a problem with how manually adding/triggering an option worked. Everytime the option was forcefully selected, it would a duplicate item to the option list. So, I've added a check for that. This fix might actually make the duplicate check redundant, as I believe that was the cause for the duplicates.

I really don't like select2. It looks nice, and works nice most of the time, but it just has so many quirks to work around.

jQuery(function($) {
    function getSelect2Value(element) {

        // if the passed object is not a jquery object, assuming 'element' is a selector
        if (!(element instanceof jQuery)) element = $(element);

        var select = element.data("select2");

        // There's two different locations where the select2-generated input element can be. 
        searchElement = select.dropdown.$search || select.$container.find(".select2-search__field");

        var value = searchElement.val();
        return value;
    }

    $(".select2-hidden-accessible").on('select2:selecting', function (e) {
        var data = e.params.args.data;
        var isMouseUp = false;
        var element = $(this);
        var value = getSelect2Value(element);

        if(e.params.args.originalEvent) isMouseUp = e.params.args.originalEvent.type == "mouseup";

        // if selected item does not match typed text, do not allow it to pass - force close for ajax.
        if(!isMouseUp) {
            if(value.toLowerCase() && data.text.toLowerCase().indexOf(value) < 0) {
                e.preventDefault();

                element.select2('close');

            // if it does match, we set a flag in the event (which gets passed to subsequent events), telling it not to worry about the ajax
            } else if(value.toLowerCase() && data.text.toLowerCase().indexOf(value) > -1) {
                e.params.args.noForceAjax = true;
            }
        }
    });

    $(".select2-hidden-accessible").on('select2:closing', function (e) {
        var element = $(this);
        var value = getSelect2Value(element);
        var noForceAjax = false;
        var isMouseUp = false;
        if(e.params.args.originalSelect2Event) noForceAjax = e.params.args.originalSelect2Event.noForceAjax;
        if(e.params.args.originalEvent) isMouseUp = e.params.args.originalEvent.type == "mouseup";

        if(value && !noForceAjax && !isMouseUp) {
            var endpoint = element.data("endpoint");
            var assetStatusType = element.data("asset-status-type");
            $.ajax({
                url: baseUrl + 'api/v1/' + endpoint + '/selectlist?search='+value+'&page=1' + (assetStatusType ? '&assetStatusType='+assetStatusType : ''),
                dataType: 'json',
                headers: {
                    "X-Requested-With": 'XMLHttpRequest',
                    "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
                },
            }).done(function(response) {;
                var currentlySelected = element.select2('data').map(x => +x.id).filter(x => {return x !== 0});

                // makes sure we're not selecting the same thing twice for multiples
                var filteredResponse = response.items.filter(function(item) {
                    return currentlySelected.indexOf(+item.id) < 0;
                });

                var first = (currentlySelected.length > 0) ? filteredResponse[0] : response.items[0];

                if(first && first.id) {
                    first.selected = true;

                    if($("option[value='" + first.id + "']", element).length < 1) {
                        var option = new Option(first.text, first.id, true, true);
                        element.append(option);
                    } else {
                        var isMultiple = element.attr("multiple") == "multiple";
                        element.val(isMultiple? element.val().concat(first.id) : element.val(first.id));
                    }
                    element.trigger('change');

                    element.trigger({
                        type: 'select2:select',
                        params: {
                            data: first
                        }
                    });

                }
            });
        }

    });
});
tilldeeke commented 6 years ago

I tried out your script by copying it into the @section('moar_scripts') section in the bulk checkout template. When your code is inserted, the dropdown only closes, if I select an entry or the searchbar is empty 🤔 mnf5xoch1i

If I remove your script, the dropdown closes when I click outside of it: dwiejv9nd0

takuy commented 6 years ago

Definitely odd. There's nothing in my code that would prevent that behavior. Haven't run into that with my tests so far either. Wonder if it has something to do with the order things are loaded.

Any errors in the dev console?

What happens if it's loaded in resources/assets/js/snipeit.js? after select2 is initiated for the whole page

tilldeeke commented 6 years ago

Ahh, sorry! I should have checked the console 🙄😅

screen shot 2018-07-30 at 00 09 04

Because of some changes on the develop branch, the baseUrl variable isn't set. @snipe fixed that for the rest of the code in this commit: 3df19d267f76b47f4df24792ff3fe896ab16704b

If the variable is set (or replaced with Ziggy.baseUrl) the dropdown closes.

takuy commented 6 years ago

Okay, great; that makes sense. I'll see if I can find build it into a dev environment and submit a PR later today then.

snipe commented 6 years ago

This is merged into develop now. You can test it on https://develop.snipeitapp.com

snipe commented 6 years ago

@takuy This is slightly off topic, but can you describe your NFC setup? I think this could be a really interesting use case to highlight. (In fact, if you're willing, it would be super neat to write up a case study for the website.)

takuy commented 6 years ago

Sure - since we're an academic institution, we have a vendor (Blackboard, Inc) that provides systems for students, faculty & stuff ID cards, which include both a magstrip and an NFC tag (they're Sony FeliCa chips). They also provide the transaction system that controls money stored on the cards or varying levels of door access. The NFC tag on these cards are encrypted and it's nearly impossible to get any info off them by standard means. Blackboard offers various proprietary readers (connect via USB or Bluetooth) that can read the tag though. We have one administrator at the institution who has the software to manage/program how data is read/transformed and then output from these readers.

In my specific case (and most cases where someone at the institution is using a Blackboard reader connected to a PC), it's set up as a HID; so it's acting like a keyboard wedge. It types the user's university ID and submits a carriage return. No extra software required for processing since the reader has the built-in capability for that.

I'm sure this is a lot more complicated for other non-academic usecases... as if HID/keyboard wedge functionality isn't built in to your reader (most vendors/NFC companies usually offer one with that option though), you need intermediary software/scripts to read and transform the data that's read off the cards. This is possible via software like TWedge, GoToTags, IDBLUE, or can be hacked together with python scripts or AutoHotKey. Of course, the software has to support that specific reader.