Allow drag 'n drop of raid responses.

This commit is contained in:
Casper V. Kristensen 2020-09-17 18:03:49 +02:00
parent a013d157ff
commit fb6fcc3307
Signed by: caspervk
GPG key ID: 289CA03790535054
8 changed files with 174 additions and 77 deletions

View file

@ -20,104 +20,69 @@
.bg-warrior, .bg-class-9 {background-color: #c79c6e; color: #fff;}
/* WoW class-coloured buttons */
.btn-druid, .btn-class-1 {
/* WoW class-coloured alerts */
.alert-druid, .alert-class-1 {
color: #fff;
background-color: #ff7d0a;
border-color: #ff7d0a;
}
.btn-druid:hover, .btn-class-1:hover {
color: #fff;
background-color: #eb7c0a;
border-color: #eb7c0a;
}
.btn-hunter, .btn-class-2 {
.alert-hunter, .alert-class-2 {
color: #212529;
background-color: #abd473;
border-color: #abd473;
}
.btn-hunter:hover, .btn-class-2:hover {
color: #212529;
background-color: #98c062;
border-color: #98c062;
}
.btn-mage, .btn-class-3 {
.alert-mage, .alert-class-3 {
color: #fff;
background-color: #69ccf0;
border-color: #69ccf0;
}
.btn-mage:hover, .btn-class-3:hover {
color: #fff;
background-color: #67b8dc;
border-color: #67b8dc;
}
.btn-paladin, .btn-class-4 {
.alert-paladin, .alert-class-4 {
color: #fff;
background-color: #f58cba;
border-color: #f58cba;
}
.btn-paladin:hover, .btn-class-4:hover {
color: #fff;
background-color: #e17aa7;
border-color: #e17aa7;
}
.btn-priest, .btn-class-5 {
.alert-priest, .alert-class-5 {
color: #212529;
background-color: #ffffff;
border-color: #ced4da;
}
.btn-priest:hover, .btn-class-5:hover {
color: #212529;
background-color: #ebebeb;
border-color: #ced2d6;
}
.btn-rogue, .btn-class-6 {
.alert-rogue, .alert-class-6 {
color: #212529;
background-color: #fff569;
border-color: #f5eb63;
}
.btn-rogue:hover, .btn-class-6:hover {
color: #212529;
background-color: #ebe168;
border-color: #e1d761;
}
.btn-shaman, .btn-class-7 {
.alert-shaman, .alert-class-7 {
color: #fff;
background-color: #0070de;
border-color: #0070de;
}
.btn-shaman:hover, .btn-class-7:hover {
color: #fff;
background-color: #0065ca;
border-color: #0065ca;
}
.btn-warlock, .btn-class-8 {
.alert-warlock, .alert-class-8 {
color: #fff;
background-color: #9482c9;
border-color: #9482c9;
}
.btn-warlock:hover, .btn-class-8:hover {
color: #fff;
background-color: #816fb5;
border-color: #816fb5;
}
.btn-warrior, .btn-class-9 {
.alert-warrior, .alert-class-9 {
color: #fff;
background-color: #c79c6e;
border-color: #c79c6e;
}
.btn-warrior:hover, .btn-class-9:hover {
color: #fff;
background-color: #b3895c;
border-color: #b3895c;
.response-character-alert {
width: 16ch;
padding: .375rem .75rem; /* from bootstrap's .btn */
text-align: center;
touch-action: none; /* required by interact.js */
user-select: none; /* required by interact.js */
-moz-user-select: none; /* required by interact.js */
}
@ -210,8 +175,3 @@ a.response-status-confirmed-bg:hover, a.response-status-5-bg:hover {
.o-50 {
opacity: 0.5;
}
.response-character-button {
width: 16ch;
}

3
drakul/base/static/js/interact.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
function getCookie(name) {
// From https://docs.djangoproject.com/en/3.1/ref/csrf/
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}

View file

@ -61,6 +61,8 @@
<!-- We're only really using JavaScript to control the navbar expansion for mobile devices.. -->
<script src="{% static 'js/jquery.slim.min.js' %}"></script>
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
<script src="{% static 'js/interact.min.js' %}"></script>
<script src="{% static 'js/main.js' %}"></script>
{% block scripts %}{% endblock scripts %}
</body>
</html>

View file

@ -65,10 +65,16 @@ class RaidResponseForm(ModelForm):
self.helper.form_tag = False
def clean_role(self):
role = self.cleaned_data["role"]
if role is None:
return self.cleaned_data["character"].role
return role
return self.cleaned_data["role"] or self.cleaned_data["character"].role
class RaidResponseChangeForm(ModelForm):
class Meta:
model = RaidResponse
fields = ["role", "status", "group"]
def clean_role(self):
return self.cleaned_data["role"] or self.instance.character.role
class RaidForm(ModelForm):

View file

@ -52,14 +52,13 @@
</div>
</div>
{% regroup raid.responses.all by get_status_display as status_responses_list %}
{% for status, status_responses in status_responses_list %}
{% regroup status_responses by get_group_display as group_responses_list %}
{% for status, status_responses in raid_responses.items %}
{% regroup status_responses by group as group_responses_list %}
{% for group, group_responses in group_responses_list %}
<div class="card mb-2">
<h6 class="card-header d-flex response-status-{{ status | slugify }}-bg">
<div class="card mb-2 response-container" data-response-container-status="{{ status }}" data-response-container-group="{{ group }}">
<h6 class="card-header d-flex response-status-{{ status }}-bg">
<span class="mr-auto">
{{ status }}{% if group_responses_list|length > 1 %}: {{ group }}{% endif %} ({{ group_responses | length }})
{{ status.label }}{% if group_responses_list|length > 1 %}: {{ group_responses.0.get_group_display }}{% endif %} ({{ group_responses | length }})
</span>
<span type="button" class="badge badge-dark" data-toggle="modal" data-target="#exportModal">Export</span>
</h6>
@ -75,19 +74,22 @@
<div class="d-flex flex-column mr-2 mb-4">
{% regroup class_responses by character.user.rank as rank_responses_list %}
{% for rank, rank_responses in rank_responses_list %}
<!-- TODO: Uncomment to show user ranks in raid signup
<div class="d-flex justify-content-center align-items-center">
<hr class="flex-grow-1 my-0">
<div class="mx-2 my-0 font-weight-light text-muted small">{{ rank }}</div>
<hr class="flex-grow-1 my-0">
</div>
-->
{% for response in rank_responses %}
<a class="btn response-character-button mb-1 btn-class-{{ response.character.klass }}" role="button" href="#">
<div class="alert response-character-alert mb-1 alert-class-{{ response.character.klass }}" role="alert" data-response-id="{{ response.id }}"
{% if response.role is not None %} data-response-role="{{ response.role }}"{% endif %}>
{% if response.character != response.character.user.main %}
<abbr title="{{ response.character.user.main }}">{{ response.character.name }}</abbr>
{% else %}
{{ response.character.name }}
{% endif %}
</a>
</div>
{% endfor %}
{% endfor %}
</div>
@ -96,6 +98,14 @@
{% endfor %}
</div>
</div>
{% empty %}
{% if perms.raids.change_raid %}
<div class="card mb-2 response-container" style="opacity: 0.4" data-response-container-status="{{ status }}" data-response-container-group="1">
<h6 class="card-header d-flex response-status-{{ status }}-bg">
<span class="mr-auto">{{ status.label }}</span>
</h6>
</div>
{% endif %}
{% endfor %}
{% endfor %}
@ -129,11 +139,81 @@
{% block scripts %}
<script>
// Export button
const charactersTextinput = document.querySelector("#characters-textinput");
$("#exportModal").on("show.bs.modal", function (event) {
charactersTextinput.textContent = $(event.relatedTarget).closest(".card").find(".response-character-button").map(function () {
return this.text.trim();
charactersTextinput.textContent = $(event.relatedTarget).closest(".card").find(".response-character-alert").map(function () {
return this.innerText.trim();
}).get().join("\n");
})
});
{% if perms.raids.change_raid %}
// Raid response dragging
const csrftoken = getCookie("csrftoken");
let original;
const position = {};
interact(".response-character-alert").draggable({
manualStart: true,
listeners: {
start(event) {
original.style.opacity = "0.5";
let boundingClientRect = original.getBoundingClientRect();
event.target.style.position = "fixed";
event.target.style.top = boundingClientRect.top + "px";
event.target.style.left = boundingClientRect.left + "px";
position.x = 0;
position.y = 0;
position.pageYOffset = window.pageYOffset;
},
move(event) {
position.x += event.dx;
position.y += event.dy;
event.target.style.transform = `translate(${position.x}px, ${position.y + (position.pageYOffset - window.pageYOffset)}px)`;
},
end(event) {
// remove the clone and restore opacity on the original element
event.target.remove();
original.style.opacity = null;
},
}
}).on("move", function (event) {
// Move clone of draggable elements instead of original (https://interactjs.io/docs/faq#clone-target-draggable)
// return if the pointer was moved without being held down or an interaction is already in progress
if (!event.interaction.pointerIsDown || event.interaction.interacting()) {
return;
}
original = event.currentTarget;
let clone = original.cloneNode({deep: true});
document.body.appendChild(clone);
event.interaction.start({name: "drag"}, event.interactable, clone);
});
interact(".response-container").dropzone({
accept: ".response-character-alert",
ondrop(event) {
// ignore if dropped into its original dropzone
if (event.target.closest(".response-container") === original.closest(".response-container")) {
return;
}
let form = new FormData();
if (event.relatedTarget.dataset.responseRole) {
form.append("role", event.relatedTarget.dataset.responseRole);
}
form.append("status", event.target.dataset.responseContainerStatus);
form.append("group", event.target.dataset.responseContainerGroup);
fetch(`/raids/responses/${event.relatedTarget.dataset.responseId}/change/`, {
method: "POST",
headers: {
"X-CSRFToken": csrftoken,
},
mode: "same-origin",
body: form,
}).then(function (response) {
location.reload();
});
}
});
{% endif %}
</script>
{% endblock scripts %}

View file

@ -9,4 +9,5 @@ urlpatterns = [
path("raids/<int:pk>/", views.RaidDetailView.as_view(), name="raid_detail"),
path("raids/<int:pk>/change/", views.RaidChangeView.as_view(), name="raid_change"),
path("raids/<int:pk>/delete/", views.RaidDeleteView.as_view(), name="raid_delete"),
path("raids/responses/<int:pk>/change/", views.RaidResponseChangeView.as_view(), name="raid_response_change"),
]

View file

@ -5,14 +5,16 @@ from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Q, Max
from django.http import Http404
from django.http import Http404, HttpResponse
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.views.generic import DeleteView, CreateView, MonthArchiveView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import BaseUpdateView
from drakul.base.views import MultiModelFormView
from .forms import RaidResponseForm, RaidCommentForm, RaidForm, RaidResponseFormSetHelper, RaidResponseFormSet
from .forms import RaidResponseForm, RaidCommentForm, RaidForm, RaidResponseFormSetHelper, RaidResponseFormSet, \
RaidResponseChangeForm
from .models import Raid, RaidResponse, InstanceReset
User = get_user_model()
@ -95,6 +97,17 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
"comments__user__main"
).all()
def get_context_data(self, **kwargs):
"""
Include empty RaidResponse categories (statuses) returned for a given raid, to allow drag 'n dropping into it.
"""
context = super().get_context_data(**kwargs)
raid_responses = {status: [] for status in reversed(RaidResponse.Status)}
for status, responses in itertools.groupby(context["raid"].responses.all(), key=lambda r: r.status):
raid_responses[status] = list(responses)
context["raid_responses"] = raid_responses
return context
def get_form_classes(self):
if not self.request.user.is_authenticated:
return {}
@ -140,6 +153,22 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
return super().post(request, *args, **kwargs)
class RaidResponseChangeView(PermissionRequiredMixin, BaseUpdateView):
"""
For RaidResponse drag 'n dropping.
"""
permission_required = "raids.change_raid"
model = RaidResponse
form_class = RaidResponseChangeForm
def form_valid(self, form):
form.save()
return HttpResponse(status=204)
def form_invalid(self, form):
return HttpResponse(form, status=400)
class RaidCreateView(PermissionRequiredMixin, CreateView):
permission_required = "raids.add_raid"
model = Raid