wip: use autocomplete to add users in groups

This commit is contained in:
Yohan Boniface 2024-08-21 11:34:59 +02:00
parent 1058e6074f
commit 6b6be017bb
8 changed files with 144 additions and 14 deletions

View file

@ -113,11 +113,25 @@ class UserProfileForm(forms.ModelForm):
fields = ("username", "first_name", "last_name") fields = ("username", "first_name", "last_name")
class GroupMembersField(forms.ModelMultipleChoiceField):
def set_choices(self, choices):
iterator = self.iterator(self)
# Override queryset so to expose only selected choices:
# - we don't want a select with 100000 options
# - the select values will be used by the autocomplete widget to display
# already existing members of the group
iterator.queryset = choices
self.choices = iterator
class GroupForm(forms.ModelForm): class GroupForm(forms.ModelForm):
class Meta: class Meta:
model = Group model = Group
fields = ["name", "members"] fields = ["name", "members"]
members = forms.ModelMultipleChoiceField( def __init__(self, *args, **kwargs):
queryset=User.objects.all(), widget=forms.CheckboxSelectMultiple super().__init__(*args, **kwargs)
) self.fields["members"].set_choices(self.initial["members"])
self.fields["members"].widget.attrs["hidden"] = "hidden"
members = GroupMembersField(queryset=User.objects.all())

View file

@ -273,9 +273,7 @@ ul.umap-autocomplete {
border: 1px solid #202425; border: 1px solid #202425;
padding: 7px; padding: 7px;
color: #eeeeec; color: #eeeeec;
} margin-bottom: 7px;
.umap-multiresult li + li {
margin-top: 7px;
} }
.umap-singleresult div .close, .umap-singleresult div .close,
.umap-multiresult li .close { .umap-multiresult li .close {

View file

@ -9,8 +9,8 @@ import { Request, ServerRequest } from './request.js'
import { escapeHTML, generateId } from './utils.js' import { escapeHTML, generateId } from './utils.js'
export class BaseAutocomplete { export class BaseAutocomplete {
constructor(el, options) { constructor(parent, options) {
this.el = el this.parent = parent
this.options = { this.options = {
placeholder: translate('Start typing...'), placeholder: translate('Start typing...'),
emptyMessage: translate('No result'), emptyMessage: translate('No result'),
@ -43,7 +43,7 @@ export class BaseAutocomplete {
this.input = DomUtil.element({ this.input = DomUtil.element({
tagName: 'input', tagName: 'input',
type: 'text', type: 'text',
parent: this.el, parent: this.parent,
placeholder: this.options.placeholder, placeholder: this.options.placeholder,
autocomplete: 'off', autocomplete: 'off',
className: this.options.className, className: this.options.className,

View file

@ -25,4 +25,34 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script type="module" defer>
const form = document.querySelector("#group_form")
const select = form.querySelector('#id_members')
function onSelect({item: {value, label}}) {
const option = document.createElement('option')
option.value = value
option.textContent = label
option.selected = "selected"
select.appendChild(option)
}
function onUnselect({item: {value, label}}) {
const option = select.querySelector(`[value="${value}"]`)
select.removeChild(option)
}
const options = {
className: 'edit-group-members',
on_select: onSelect,
on_unselect: onUnselect,
placeholder: "{% trans "Add user" %}"
}
const autocomplete = new U.AjaxAutocompleteMultiple(form, options)
for (const option of select.options) {
autocomplete.displaySelected({
item: { value: option.value, label: option.textContent },
})
}
const submit = form.querySelector('input[type="submit"]')
// Move it after the autocomplete widget.
form.appendChild(submit)
</script>
{% endblock maincontent %} {% endblock maincontent %}

View file

@ -0,0 +1,46 @@
import re
import pytest
from django.contrib.auth.models import Group
pytestmark = pytest.mark.django_db
def test_can_add_user_to_group(live_server, map, user, group, login):
map.owner.groups.add(group)
map.owner.save()
assert Group.objects.count() == 1
page = login(map.owner)
with page.expect_navigation():
page.get_by_role("link", name="My teams").click()
with page.expect_navigation():
page.get_by_role("link", name="Edit").click()
page.get_by_placeholder("Add user").click()
with page.expect_response(re.compile(r".*/agnocomplete/.*")):
page.get_by_placeholder("Add user").press_sequentially("joe")
page.get_by_text("Joe").click()
page.get_by_role("button", name="Save").click()
assert Group.objects.count() == 1
modified = Group.objects.first()
assert user in modified.user_set.all()
def test_can_remove_user_from_group(live_server, map, user, user2, group, login):
map.owner.groups.add(group)
map.owner.save()
user.groups.add(group)
user.save()
user2.groups.add(group)
user2.save()
assert Group.objects.count() == 1
page = login(map.owner)
with page.expect_navigation():
page.get_by_role("link", name="My teams").click()
with page.expect_navigation():
page.get_by_role("link", name="Edit").click()
page.locator("li").filter(has_text="Averell").locator(".close").click()
page.get_by_role("button", name="Save").click()
assert Group.objects.count() == 1
modified = Group.objects.first()
assert user in modified.user_set.all()
assert user2 not in modified.user_set.all()

View file

@ -37,7 +37,7 @@ def test_can_create_a_group(client, user):
assert group in user.groups.all() assert group in user.groups.all()
def test_can_edit_a_group(client, user, group): def test_can_edit_a_group_name(client, user, group):
user.groups.add(group) user.groups.add(group)
user.save() user.save()
assert Group.objects.count() == 1 assert Group.objects.count() == 1
@ -52,6 +52,38 @@ def test_can_edit_a_group(client, user, group):
assert modified in user.groups.all() assert modified in user.groups.all()
def test_can_add_user_to_group(client, user, user2, group):
user.groups.add(group)
user.save()
assert Group.objects.count() == 1
url = reverse("group_update", args=(group.pk,))
client.login(username=user.username, password="123123")
response = client.post(url, {"name": group.name, "members": [user.pk, user2.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/groups"
assert Group.objects.count() == 1
modified = Group.objects.first()
assert user in modified.user_set.all()
assert user2 in modified.user_set.all()
def test_can_remove_user_from_group(client, user, user2, group):
user.groups.add(group)
user.save()
user2.groups.add(group)
user2.save()
assert Group.objects.count() == 1
url = reverse("group_update", args=(group.pk,))
client.login(username=user.username, password="123123")
response = client.post(url, {"name": group.name, "members": [user.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/groups"
assert Group.objects.count() == 1
modified = Group.objects.first()
assert user in modified.user_set.all()
assert user2 not in modified.user_set.all()
def test_cannot_edit_a_group_if_not_member(client, user, user2, group): def test_cannot_edit_a_group_if_not_member(client, user, user2, group):
user.groups.add(group) user.groups.add(group)
user.save() user.save()

View file

@ -25,6 +25,10 @@ admin.autodiscover()
urlpatterns = [ urlpatterns = [
re_path(r"^admin/", admin.site.urls), re_path(r"^admin/", admin.site.urls),
re_path("", include("social_django.urls", namespace="social")), re_path("", include("social_django.urls", namespace="social")),
re_path(
r"^agnocomplete/",
include(("agnocomplete.urls", "agnocomplete"), namespace="agnocomplete"),
),
re_path(r"^m/(?P<pk>\d+)/$", views.MapShortUrl.as_view(), name="map_short_url"), re_path(r"^m/(?P<pk>\d+)/$", views.MapShortUrl.as_view(), name="map_short_url"),
re_path(r"^ajax-proxy/$", cache_page(180)(views.ajax_proxy), name="ajax-proxy"), re_path(r"^ajax-proxy/$", cache_page(180)(views.ajax_proxy), name="ajax-proxy"),
re_path( re_path(
@ -40,7 +44,6 @@ urlpatterns = [
name="password_change_done", name="password_change_done",
), ),
re_path(r"^i18n/", include("django.conf.urls.i18n")), re_path(r"^i18n/", include("django.conf.urls.i18n")),
re_path(r"^agnocomplete/", include("agnocomplete.urls")),
re_path(r"^map/oembed/", views.MapOEmbed.as_view(), name="map_oembed"), re_path(r"^map/oembed/", views.MapOEmbed.as_view(), name="map_oembed"),
re_path( re_path(
r"^map/(?P<map_id>\d+)/download/", r"^map/(?P<map_id>\d+)/download/",

View file

@ -213,9 +213,16 @@ class GroupUpdate(UpdateView):
return initial return initial
def form_valid(self, form): def form_valid(self, form):
for user in form.cleaned_data["members"]: actual = self.object.user_set.all()
user.groups.add(self.object) wanted = form.cleaned_data["members"]
user.save() for user in wanted:
if user not in actual:
user.groups.add(self.object)
user.save()
for user in actual:
if user not in wanted:
user.groups.remove(self.object)
user.save()
return super().form_valid(form) return super().form_valid(form)