Room reservation form

This commit is contained in:
imperosol 2025-06-27 20:30:28 +02:00
parent de7caea9a5
commit 5b29250b39
14 changed files with 215 additions and 6 deletions

View File

@ -1,7 +1,8 @@
import { morph } from "@alpinejs/morph";
import sort from "@alpinejs/sort"; import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
Alpine.plugin(sort); Alpine.plugin([sort, morph]);
window.Alpine = Alpine; window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {

View File

@ -1,4 +1,5 @@
import htmx from "htmx.org"; import htmx from "htmx.org";
import "htmx-ext-alpine-morph";
document.body.addEventListener("htmx:beforeRequest", (event) => { document.body.addEventListener("htmx:beforeRequest", (event) => {
event.target.ariaBusy = true; event.target.ariaBusy = true;

View File

@ -109,7 +109,7 @@ class FragmentMixin(TemplateResponseMixin, ContextMixin):
return render( return render(
request, request,
"app/template.jinja", "app/template.jinja",
context={"fragment": fragment(request) context={"fragment": fragment(request)}
} }
# in urls.py # in urls.py

16
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "3", "version": "3",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.14.7", "@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
@ -33,6 +34,7 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@ -58,6 +60,12 @@
"vite-plugin-static-copy": "^3.0.2" "vite-plugin-static-copy": "^3.0.2"
} }
}, },
"node_modules/@alpinejs/morph": {
"version": "3.14.9",
"resolved": "https://registry.npmjs.org/@alpinejs/morph/-/morph-3.14.9.tgz",
"integrity": "sha512-i1mrH5Gza/egszxnCVwQWypRhsKGq28RFWHWuW7aI0+rWo1pvFw+aPhJLImbpt7hx44DtDOr5m4l9Ah+JPFmFw==",
"license": "MIT"
},
"node_modules/@alpinejs/sort": { "node_modules/@alpinejs/sort": {
"version": "3.14.9", "version": "3.14.9",
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.9.tgz", "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.9.tgz",
@ -4233,6 +4241,14 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/htmx-ext-alpine-morph": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/htmx-ext-alpine-morph/-/htmx-ext-alpine-morph-2.0.1.tgz",
"integrity": "sha512-teGpcVatx5IjDUYQs959x9FcePM1TIksjfW5tSe1KVQVEVSmbGxEoemneC7XV6RYpX+27i/xn1fPjduwvHDrAw==",
"dependencies": {
"htmx.org": "^2.0.2"
}
},
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",

View File

@ -40,6 +40,7 @@
"vite-plugin-static-copy": "^3.0.2" "vite-plugin-static-copy": "^3.0.2"
}, },
"dependencies": { "dependencies": {
"@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.14.7", "@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
@ -64,6 +65,7 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",

View File

@ -2,7 +2,8 @@ from django import forms
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User from core.models import User
from reservation.models import Room from core.views.forms import FutureDateTimeField, SelectDateTime
from reservation.models import ReservationSlot, Room
class RoomCreateForm(forms.ModelForm): class RoomCreateForm(forms.ModelForm):
@ -31,3 +32,22 @@ class RoomUpdateForm(forms.ModelForm):
# (i.e. it's a club board member, but not a sith admin) # (i.e. it's a club board member, but not a sith admin)
# some fields aren't editable # some fields aren't editable
del self.fields["club"] del self.fields["club"]
class ReservationForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta:
model = ReservationSlot
fields = ["room", "start_at", "end_at", "comment"]
field_classes = {"start_at": FutureDateTimeField, "end_at": FutureDateTimeField}
widgets = {"start_at": SelectDateTime(), "end_at": SelectDateTime()}
def __init__(self, *args, author: User, **kwargs):
super().__init__(*args, **kwargs)
self.author = author
def save(self, commit: bool = True): # noqa FBT001
self.instance.author = self.author
return super().save(commit)

View File

@ -1,6 +1,7 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { import {
Calendar, Calendar,
type DateSelectArg,
type EventDropArg, type EventDropArg,
type EventSourceFuncArg, type EventSourceFuncArg,
} from "@fullcalendar/core"; } from "@fullcalendar/core";
@ -18,6 +19,7 @@ import {
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api";
import interactionPlugin from "@fullcalendar/interaction"; import interactionPlugin from "@fullcalendar/interaction";
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"; import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
@registerComponent("room-scheduler") @registerComponent("room-scheduler")
export class RoomScheduler extends inheritHtmlElement("div") { export class RoomScheduler extends inheritHtmlElement("div") {
@ -26,6 +28,7 @@ export class RoomScheduler extends inheritHtmlElement("div") {
private locale = "en"; private locale = "en";
private canEditSlot = false; private canEditSlot = false;
private canBookSlot = false; private canBookSlot = false;
private canDeleteSlot = false;
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) { attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") { if (name === "locale") {
@ -37,6 +40,9 @@ export class RoomScheduler extends inheritHtmlElement("div") {
if (name === "can_create_slot") { if (name === "can_create_slot") {
this.canBookSlot = newValue.toLowerCase() === "true"; this.canBookSlot = newValue.toLowerCase() === "true";
} }
if (name === "can_delete_slot") {
this.canDeleteSlot = newValue.toLowerCase() === "true";
}
} }
/** /**
@ -84,6 +90,18 @@ export class RoomScheduler extends inheritHtmlElement("div") {
} }
} }
selectFreeSlot(infos: DateSelectArg) {
document.dispatchEvent(
new CustomEvent<SlotSelectedEventArg>("timeSlotSelected", {
detail: {
ressource: Number.parseInt(infos.resource.id),
start: infos.startStr,
end: infos.endStr,
},
}),
);
}
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.scheduler = new Calendar(this.node, { this.scheduler = new Calendar(this.node, {
@ -109,6 +127,7 @@ export class RoomScheduler extends inheritHtmlElement("div") {
resourceAreaWidth: "20%", resourceAreaWidth: "20%",
resources: this.fetchResources, resources: this.fetchResources,
events: this.fetchEvents, events: this.fetchEvents,
select: this.selectFreeSlot,
selectOverlap: false, selectOverlap: false,
selectable: this.canBookSlot, selectable: this.canBookSlot,
selectConstraint: { start: new Date() }, selectConstraint: { start: new Date() },

View File

@ -0,0 +1,23 @@
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
document.addEventListener("alpine:init", () => {
Alpine.data("slotReservation", () => ({
start: null as string,
end: null as string,
room: null as number,
showForm: false,
init() {
document.addEventListener(
"timeSlotSelected",
(event: CustomEvent<SlotSelectedEventArg>) => {
this.start = event.detail.start.split("+")[0];
this.end = event.detail.end.split("+")[0];
this.room = event.detail.ressource;
this.showForm = true;
this.$nextTick(() => this.$el.scrollIntoView({ behavior: "smooth" })).then();
},
);
},
}));
});

View File

@ -0,0 +1,5 @@
export interface SlotSelectedEventArg {
start: string;
end: string;
ressource: number;
}

View File

@ -0,0 +1,39 @@
#slot-reservation {
margin-top: 3em;
display: flex;
flex-direction: column;
justify-content: center;
h3 {
display: block;
margin: auto;
text-align: left;
}
.alert, .error {
display: block;
margin: 1em auto auto;
max-width: 400px;
word-wrap: break-word;
text-wrap: wrap;
}
form {
display: flex;
flex-direction: column;
gap: .5em;
justify-content: center;
.buttons-row {
input[type="submit"], button {
margin: 0;
}
}
textarea {
max-width: unset;
width: 100%;
margin-top: unset;
}
}
}

View File

@ -0,0 +1,51 @@
<section
id="slot-reservation"
x-data="slotReservation"
x-show="showForm"
hx-target="this"
hx-ext="alpine-morph"
hx-swap="morph"
>
<h3>{% trans %}Book a room{% endtrans %}</h3>
{% set non_field_errors = form.non_field_errors() %}
{% if non_field_errors %}
<div class="alert alert-red">
{% for error in non_field_errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
<form
id="slot-reservation-form"
hx-post="{{ url("reservation:make_reservation") }}"
hx-disabled-elt="find input[type='submit']"
>
{% csrf_token %}
<div class="form-group">
{{ form.room.errors }}
{{ form.room.label_tag() }}
{{ form.room|add_attr("x-model=room") }}
</div>
<div class="form-group">
{{ form.start_at.errors }}
{{ form.start_at.label_tag() }}
{{ form.start_at|add_attr("x-model=start") }}
</div>
<div class="form-group">
{{ form.end_at.errors }}
{{ form.end_at.label_tag() }}
{{ form.end_at|add_attr("x-model=end") }}
</div>
<div class="form-group">
{{ form.comment.errors }}
{{ form.comment.label_tag() }}
{{ form.comment }}
</div>
<div class="row gap buttons-row">
<button class="btn btn-grey grow" @click.prevent="showForm = false">
{% trans %}Cancel{% endtrans %}
</button>
<input class="btn btn-blue grow" type="submit">
</div>
</form>
</section>

View File

@ -18,4 +18,8 @@
can_edit_slot="{{ user.has_perm("reservation.change_reservationslot") }}" can_edit_slot="{{ user.has_perm("reservation.change_reservationslot") }}"
can_create_slot="{{ user.has_perm("reservation.add_reservationslot") }}" can_create_slot="{{ user.has_perm("reservation.add_reservationslot") }}"
></room-scheduler> ></room-scheduler>
{% if user.has_perm("reservation.add_reservationslot") %}
<p><em>{% trans %}You can book a room by selecting a free slot in the calendar.{% endtrans %}</em></p>
{{ add_slot_fragment }}
{% endif %}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from reservation.views import ( from reservation.views import (
ReservationFragment,
ReservationScheduleView, ReservationScheduleView,
RoomCreateView, RoomCreateView,
RoomDeleteView, RoomDeleteView,
@ -12,4 +13,7 @@ urlpatterns = [
path("room/create/", RoomCreateView.as_view(), name="room_create"), path("room/create/", RoomCreateView.as_view(), name="room_create"),
path("room/<int:room_id>/edit", RoomUpdateView.as_view(), name="room_edit"), path("room/<int:room_id>/edit", RoomUpdateView.as_view(), name="room_edit"),
path("room/<int:room_id>/delete", RoomDeleteView.as_view(), name="room_delete"), path("room/<int:room_id>/delete", RoomDeleteView.as_view(), name="room_delete"),
path(
"fragment/reservation", ReservationFragment.as_view(), name="make_reservation"
),
] ]

View File

@ -8,13 +8,37 @@ from django.views.generic import CreateView, DeleteView, TemplateView, UpdateVie
from club.models import Club from club.models import Club
from core.auth.mixins import CanEditMixin from core.auth.mixins import CanEditMixin
from reservation.forms import RoomCreateForm, RoomUpdateForm from core.views import UseFragmentsMixin
from reservation.models import Room from core.views.mixins import FragmentMixin
from reservation.forms import ReservationForm, RoomCreateForm, RoomUpdateForm
from reservation.models import ReservationSlot, Room
class ReservationScheduleView(PermissionRequiredMixin, TemplateView): class ReservationFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
model = ReservationSlot
form_class = ReservationForm
permission_required = "reservation.add_reservationslot"
template_name = "reservation/fragments/create_reservation.jinja"
success_url = reverse_lazy("reservation:main")
reload_on_redirect = True
object = None
def get_form_kwargs(self):
return super().get_form_kwargs() | {"author": self.request.user}
def get_context_data(self, **kwargs):
# if method is POST, then it means the form just failed
# to validate, so it must be shown, with error messages.
# Else, it can be initially hidden.
return super().get_context_data(**kwargs) | {
"displayForm": self.request.method == "POST"
}
class ReservationScheduleView(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
template_name = "reservation/schedule.jinja" template_name = "reservation/schedule.jinja"
permission_required = "reservation.view_room" permission_required = "reservation.view_room"
fragments = {"add_slot_fragment": ReservationFragment}
class RoomCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView): class RoomCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView):