datasette / datasette-acl

Advanced permission management for Datasette
Apache License 2.0
1 stars 0 forks source link

Differentiate display from user ID and improve user selection #23

Closed simonw closed 4 days ago

simonw commented 1 week ago

The autocomplete from this issue:

Isn't fit for purpose on Datasette Cloud, where user IDs are integers.

Need to be able to display their "display names" in lists of e.g. members of a group, and also autocomplete against those when adding users to groups or to table permissions.

Also: the <datalist> autocomplete really isn't very good - it still allows freeform text input and, at least on Firefox, shows a whole butch of irrelevant suggestions mixed in with the "valid" options:

CleanShot 2024-09-02 at 11 57 06@2x

simonw commented 1 week ago

I can use the actors_from_ids mechanism to show better actors, and I can update the design of the datasette_acl_actor_ids plugin hook to return whole actors, not just IDs, and implement one of the JavaScript autocomplete things I considered in https://github.com/datasette/datasette-acl/issues/18#issuecomment-2323460110

simonw commented 1 week ago

From https://alphagov.github.io/accessible-autocomplete/#progressive-enhancement

If your autocomplete is meant to select from a small list of options (a few hundred), we strongly suggest that you render a <select> menu on the server, and use progressive enhancement.

Instances with more than a few hundred users will be rare, I'm going to do that.

simonw commented 1 week ago

I tried getting the alphagov one working - it's pretty glitchy for me:

CleanShot 2024-09-02 at 12 39 02@2x

I had to hack the source code and add the selected text here to disable the 1Password icon on it too:

CleanShot 2024-09-02 at 12 39 43@2x

Here's my prototype:

diff --git a/datasette_acl/templates/manage_acl_group.html b/datasette_acl/templates/manage_acl_group.html
index b293f42..529b6e8 100644
--- a/datasette_acl/templates/manage_acl_group.html
+++ b/datasette_acl/templates/manage_acl_group.html
@@ -3,6 +3,8 @@
 {% block title %}{{ name }}{% endblock %}

 {% block extra_head %}
+<script src="{{ urls.static_plugins("datasette-acl", "accessible-autocomplete.min.js") }}"></script>
+<link rel="stylesheet" href="{{ urls.static_plugins("datasette-acl", "accessible-autocomplete.min.css") }}">
 <style>
 .remove-button {
   background-color: #fff;
@@ -75,14 +77,22 @@

 <form action="{{ request.path }}" method="post">
   <input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
-  <p><label>User ID <input type="text" data-1p-ignore name="add"{% if valid_actor_ids %} list="actor-ids"{% endif %}></label> <input type="submit" value="Add member"></p>
-  {% if valid_actor_ids %}
-    <datalist id="actor-ids">{% for actor_id in valid_actor_ids %}
-      <option value="{{ actor_id }}"></option>
+  <p><label for="id_add">User ID</label> <select id="id_add" name="add">
+    {% for actor_id in valid_actor_ids %}
+      <option>{{ actor_id }}</option>
     {% endfor %}
-    </datalist>
-  {% endif %}
+  </select>
 </form>
+
+
+<script>
+const userSelect = document.querySelector('#id_add');
+
+accessibleAutocomplete.enhanceSelectElement({
+  selectElement: userSelect
+});
+document.getElementById('add').setAttribute('data-1p-ignore', '');
+</script>
 {% endif %}
 {% endif %}
simonw commented 1 week ago

Got this working with https://projects.verou.me/awesomplete/

muppets

simonw commented 1 week ago

I'd prefer it if selecting the item submitted the form, you have to enter twice right now.

simonw commented 1 week ago

Here's that prototype so far (the custom event thing doesn't work though):

diff --git a/datasette_acl/templates/manage_acl_group.html b/datasette_acl/templates/manage_acl_group.html
index b293f42..9bbf7f3 100644
--- a/datasette_acl/templates/manage_acl_group.html
+++ b/datasette_acl/templates/manage_acl_group.html
@@ -3,6 +3,8 @@
 {% block title %}{{ name }}{% endblock %}

 {% block extra_head %}
+<script src="{{ urls.static_plugins("datasette-acl", "awesomplete.min.js") }}"></script>
+<link rel="stylesheet" href="{{ urls.static_plugins("datasette-acl", "awesomplete.css") }}">
 <style>
 .remove-button {
   background-color: #fff;
@@ -75,7 +77,7 @@

 <form action="{{ request.path }}" method="post">
   <input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
-  <p><label>User ID <input type="text" data-1p-ignore name="add"{% if valid_actor_ids %} list="actor-ids"{% endif %}></label> <input type="submit" value="Add member"></p>
+  <p><label>User ID <input id="id_add" type="text" data-minchars="1" data-1p-ignore name="add"{% if valid_actor_ids %} list="actor-ids"{% endif %}></label> <input type="submit" value="Add member"></p>
   {% if valid_actor_ids %}
     <datalist id="actor-ids">{% for actor_id in valid_actor_ids %}
       <option value="{{ actor_id }}"></option>
@@ -118,10 +120,16 @@
 {% endif %}

 <script>
-// Focus on add input if we just added a member
-if (window.location.hash === '#focus-add') {
-  document.querySelector('input[name="add"]').focus();
-}
+document.addEventListener('DOMContentLoaded', function() {
+  document.querySelector('#id_add').addEventListener('awesomplete-select', (ev) => {
+    console.log(ev);
+    // this.closest('form').submit();
+  });
+  // Focus on add input if we just added a member
+  if (window.location.hash === '#focus-add') {
+    document.querySelector('input[name="add"]').focus();
+  }
+});
 </script>

 {% endblock %}
simonw commented 1 week ago

I'm going to try https://choices-js.github.io/Choices/

simonw commented 1 week ago

I like Choices best:

choices

simonw commented 1 week ago

That prototoype so far:

diff --git a/datasette_acl/templates/manage_acl_group.html b/datasette_acl/templates/manage_acl_group.html
index b293f42..12b1c0b 100644
--- a/datasette_acl/templates/manage_acl_group.html
+++ b/datasette_acl/templates/manage_acl_group.html
@@ -3,6 +3,8 @@
 {% block title %}{{ name }}{% endblock %}

 {% block extra_head %}
++<script src="{{ urls.static_plugins("datasette-acl", "choices-9.0.1.min.js") }}"></script>
++<link rel="stylesheet" href="{{ urls.static_plugins("datasette-acl", "choices-9.0.1.min.css") }}">
 <style>
 .remove-button {
   background-color: #fff;
@@ -75,13 +77,17 @@

 <form action="{{ request.path }}" method="post">
   <input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
-  <p><label>User ID <input type="text" data-1p-ignore name="add"{% if valid_actor_ids %} list="actor-ids"{% endif %}></label> <input type="submit" value="Add member"></p>
-  {% if valid_actor_ids %}
-    <datalist id="actor-ids">{% for actor_id in valid_actor_ids %}
-      <option value="{{ actor_id }}"></option>
-    {% endfor %}
-    </datalist>
-  {% endif %}
+  <div style="display: flex; align-items: center; gap: 10px; max-width: 500px">
+    <label for="id_add" style="flex-shrink: 0;">User ID</label>
+    <div class="choices" data-type="select-one" tabindex="0" style="flex-grow: 1;">
+      <select id="id_add" name="add">
+        <option></option>
+        {% for actor_id in valid_actor_ids %}
+          <option>{{ actor_id }}</option>
+        {% endfor %}
+      </select>
+    </div>
+  </div>
 </form>
 {% endif %}
 {% endif %}
@@ -118,10 +124,17 @@
 {% endif %}

 <script>
-// Focus on add input if we just added a member
-if (window.location.hash === '#focus-add') {
-  document.querySelector('input[name="add"]').focus();
-}
+document.addEventListener('DOMContentLoaded', function() {
+  const select = document.querySelector('#id_add');
+  const choices = new Choices(select);
+  select.addEventListener('addItem', (ev) => {
+    ev.target.closest('form').submit()
+  });
+  // Focus on add input if we just added a member
+  if (window.location.hash === '#focus-add') {
+    choices.showDropdown();
+  }
+});
 </script>

 {% endblock %}
simonw commented 1 week ago

Claude artifact showing what it could look like if I use this rather than the table of checkboxes:

https://claude.site/artifacts/3b83782b-74d3-4759-ac68-523fe2a905eb

CleanShot 2024-09-02 at 16 14 09@2x

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Group Permissions UI</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/choices.js/10.2.0/choices.min.css">
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
            background-color: #f0f0f0;
        }
        .container {
            background-color: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        }
        h1 {
            color: #333;
        }
        .group-row {
            display: flex;
            align-items: center;
            margin-bottom: 10px;
            padding: 10px;
            background-color: #f9f9f9;
            border-radius: 4px;
        }
        .group-name {
            width: 150px;
            font-weight: bold;
            color: #4a4a4a;
        }
        select[multiple] {
            min-width: 200px;
        }
        /* Choices.js custom styles */
        .choices__inner {
            min-height: 30px;
            padding: 4px 7.5px 4px 3.75px;
        }
        .choices__list--multiple .choices__item {
            font-size: 12px;
            padding: 2px 5px;
            margin-bottom: 0;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Group Permissions</h1>
        <div id="groups-container">
            <div class="group-row">
                <span class="group-name">staff (1)</span>
                <select multiple id="select-staff">
                    <option value="insert-row">insert-row</option>
                    <option value="delete-row">delete-row</option>
                    <option value="update-row">update-row</option>
                    <option value="alter-table" selected>alter-table</option>
                    <option value="drop-table">drop-table</option>
                </select>
            </div>
            <div class="group-row">
                <span class="group-name">devs (5)</span>
                <select multiple id="select-devs">
                    <option value="insert-row" selected>insert-row</option>
                    <option value="delete-row" selected>delete-row</option>
                    <option value="update-row" selected>update-row</option>
                    <option value="alter-table" selected>alter-table</option>
                    <option value="drop-table">drop-table</option>
                </select>
            </div>
            <div class="group-row">
                <span class="group-name">newgroup (0)</span>
                <select multiple id="select-newgroup">
                    <option value="insert-row">insert-row</option>
                    <option value="delete-row">delete-row</option>
                    <option value="update-row">update-row</option>
                    <option value="alter-table" selected>alter-table</option>
                    <option value="drop-table">drop-table</option>
                </select>
            </div>
            <div class="group-row">
                <span class="group-name">muppets (5)</span>
                <select multiple id="select-muppets">
                    <option value="insert-row">insert-row</option>
                    <option value="delete-row">delete-row</option>
                    <option value="update-row">update-row</option>
                    <option value="alter-table">alter-table</option>
                    <option value="drop-table">drop-table</option>
                </select>
            </div>
        </div>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/choices.js/10.2.0/choices.min.js"></script>
    <script>
        document.addEventListener('DOMContentLoaded', function() {
            const selects = document.querySelectorAll('select[multiple]');
            selects.forEach(select => {
                new Choices(select, {
                    removeItemButton: true,
                    classNames: {
                        containerOuter: 'choices custom-choices',
                    }
                });
            });
        });
    </script>
</body>
</html>

Conversation transcript: https://gist.github.com/simonw/7b87b24cd53daf8ea05170c3c8013e3c

simonw commented 4 days ago

I implemented Choices for permission selection and user selection here:

Still need to differentiate user ID from user display though.

simonw commented 4 days ago

New datasette_acl_actor_ids hook design: it can return a list of IDs, or it can return a list of dicts with "id" and "display" keys.

simonw commented 4 days ago

I'm going to rename datasette_acl_actor_ids to datasette_acl_valid_actors.