diff --git a/core/static/bundled/alpine-index.js b/core/static/bundled/alpine-index.js index 211600a5..8bc0fec2 100644 --- a/core/static/bundled/alpine-index.js +++ b/core/static/bundled/alpine-index.js @@ -1,7 +1,8 @@ +import { morph } from "@alpinejs/morph"; import sort from "@alpinejs/sort"; import Alpine from "alpinejs"; -Alpine.plugin(sort); +Alpine.plugin([sort, morph]); window.Alpine = Alpine; window.addEventListener("DOMContentLoaded", () => { diff --git a/core/static/bundled/htmx-index.js b/core/static/bundled/htmx-index.js index 474617ac..ae00f79b 100644 --- a/core/static/bundled/htmx-index.js +++ b/core/static/bundled/htmx-index.js @@ -1,4 +1,5 @@ import htmx from "htmx.org"; +import "htmx-ext-alpine-morph"; document.body.addEventListener("htmx:beforeRequest", (event) => { event.target.ariaBusy = true; diff --git a/core/views/mixins.py b/core/views/mixins.py index 2a18955c..f5807546 100644 --- a/core/views/mixins.py +++ b/core/views/mixins.py @@ -109,7 +109,7 @@ class FragmentMixin(TemplateResponseMixin, ContextMixin): return render( request, "app/template.jinja", - context={"fragment": fragment(request) + context={"fragment": fragment(request)} } # in urls.py diff --git a/package-lock.json b/package-lock.json index 96cf66d4..bf0d781c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3", "license": "GPL-3.0-only", "dependencies": { + "@alpinejs/morph": "^3.14.9", "@alpinejs/sort": "^3.14.7", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@floating-ui/dom": "^1.6.13", @@ -33,6 +34,7 @@ "d3-force-3d": "^3.0.5", "easymde": "^2.19.0", "glob": "^11.0.0", + "htmx-ext-alpine-morph": "^2.0.1", "htmx.org": "^2.0.3", "jquery": "^3.7.1", "js-cookie": "^3.0.5", @@ -58,6 +60,12 @@ "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": { "version": "3.14.9", "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.9.tgz", @@ -4233,6 +4241,14 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", diff --git a/package.json b/package.json index 27e0ef4c..262c97a6 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "vite-plugin-static-copy": "^3.0.2" }, "dependencies": { + "@alpinejs/morph": "^3.14.9", "@alpinejs/sort": "^3.14.7", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@floating-ui/dom": "^1.6.13", @@ -64,6 +65,7 @@ "d3-force-3d": "^3.0.5", "easymde": "^2.19.0", "glob": "^11.0.0", + "htmx-ext-alpine-morph": "^2.0.1", "htmx.org": "^2.0.3", "jquery": "^3.7.1", "js-cookie": "^3.0.5", diff --git a/reservation/forms.py b/reservation/forms.py index e9eaf5d0..6b8347c2 100644 --- a/reservation/forms.py +++ b/reservation/forms.py @@ -2,7 +2,8 @@ from django import forms from club.widgets.ajax_select import AutoCompleteSelectClub 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): @@ -31,3 +32,22 @@ class RoomUpdateForm(forms.ModelForm): # (i.e. it's a club board member, but not a sith admin) # some fields aren't editable 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) diff --git a/reservation/static/bundled/reservation/components/room-scheduler-index.ts b/reservation/static/bundled/reservation/components/room-scheduler-index.ts index 0b60f263..ab0638d3 100644 --- a/reservation/static/bundled/reservation/components/room-scheduler-index.ts +++ b/reservation/static/bundled/reservation/components/room-scheduler-index.ts @@ -1,6 +1,7 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { Calendar, + type DateSelectArg, type EventDropArg, type EventSourceFuncArg, } from "@fullcalendar/core"; @@ -18,6 +19,7 @@ import { import { paginated } from "#core:utils/api"; import interactionPlugin from "@fullcalendar/interaction"; import resourceTimelinePlugin from "@fullcalendar/resource-timeline"; +import type { SlotSelectedEventArg } from "#reservation:reservation/types"; @registerComponent("room-scheduler") export class RoomScheduler extends inheritHtmlElement("div") { @@ -26,6 +28,7 @@ export class RoomScheduler extends inheritHtmlElement("div") { private locale = "en"; private canEditSlot = false; private canBookSlot = false; + private canDeleteSlot = false; attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) { if (name === "locale") { @@ -37,6 +40,9 @@ export class RoomScheduler extends inheritHtmlElement("div") { if (name === "can_create_slot") { 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("timeSlotSelected", { + detail: { + ressource: Number.parseInt(infos.resource.id), + start: infos.startStr, + end: infos.endStr, + }, + }), + ); + } + connectedCallback() { super.connectedCallback(); this.scheduler = new Calendar(this.node, { @@ -109,6 +127,7 @@ export class RoomScheduler extends inheritHtmlElement("div") { resourceAreaWidth: "20%", resources: this.fetchResources, events: this.fetchEvents, + select: this.selectFreeSlot, selectOverlap: false, selectable: this.canBookSlot, selectConstraint: { start: new Date() }, diff --git a/reservation/static/bundled/reservation/slot-reservation-index.ts b/reservation/static/bundled/reservation/slot-reservation-index.ts new file mode 100644 index 00000000..c6aa730d --- /dev/null +++ b/reservation/static/bundled/reservation/slot-reservation-index.ts @@ -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) => { + 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(); + }, + ); + }, + })); +}); diff --git a/reservation/static/bundled/reservation/types.d.ts b/reservation/static/bundled/reservation/types.d.ts new file mode 100644 index 00000000..8e6e3e33 --- /dev/null +++ b/reservation/static/bundled/reservation/types.d.ts @@ -0,0 +1,5 @@ +export interface SlotSelectedEventArg { + start: string; + end: string; + ressource: number; +} diff --git a/reservation/static/reservation/reservation.scss b/reservation/static/reservation/reservation.scss new file mode 100644 index 00000000..b553fd98 --- /dev/null +++ b/reservation/static/reservation/reservation.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/reservation/templates/reservation/fragments/create_reservation.jinja b/reservation/templates/reservation/fragments/create_reservation.jinja new file mode 100644 index 00000000..4ba2f17d --- /dev/null +++ b/reservation/templates/reservation/fragments/create_reservation.jinja @@ -0,0 +1,51 @@ +
+

{% trans %}Book a room{% endtrans %}

+ {% set non_field_errors = form.non_field_errors() %} + {% if non_field_errors %} +
+ {% for error in non_field_errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ {% csrf_token %} +
+ {{ form.room.errors }} + {{ form.room.label_tag() }} + {{ form.room|add_attr("x-model=room") }} +
+
+ {{ form.start_at.errors }} + {{ form.start_at.label_tag() }} + {{ form.start_at|add_attr("x-model=start") }} +
+
+ {{ form.end_at.errors }} + {{ form.end_at.label_tag() }} + {{ form.end_at|add_attr("x-model=end") }} +
+
+ {{ form.comment.errors }} + {{ form.comment.label_tag() }} + {{ form.comment }} +
+
+ + +
+
+
diff --git a/reservation/templates/reservation/schedule.jinja b/reservation/templates/reservation/schedule.jinja index a29f3d70..a2bd538a 100644 --- a/reservation/templates/reservation/schedule.jinja +++ b/reservation/templates/reservation/schedule.jinja @@ -18,4 +18,8 @@ can_edit_slot="{{ user.has_perm("reservation.change_reservationslot") }}" can_create_slot="{{ user.has_perm("reservation.add_reservationslot") }}" > + {% if user.has_perm("reservation.add_reservationslot") %} +

{% trans %}You can book a room by selecting a free slot in the calendar.{% endtrans %}

+ {{ add_slot_fragment }} + {% endif %} {% endblock %} \ No newline at end of file diff --git a/reservation/urls.py b/reservation/urls.py index a9912582..908415b8 100644 --- a/reservation/urls.py +++ b/reservation/urls.py @@ -1,6 +1,7 @@ from django.urls import path from reservation.views import ( + ReservationFragment, ReservationScheduleView, RoomCreateView, RoomDeleteView, @@ -12,4 +13,7 @@ urlpatterns = [ path("room/create/", RoomCreateView.as_view(), name="room_create"), path("room//edit", RoomUpdateView.as_view(), name="room_edit"), path("room//delete", RoomDeleteView.as_view(), name="room_delete"), + path( + "fragment/reservation", ReservationFragment.as_view(), name="make_reservation" + ), ] diff --git a/reservation/views.py b/reservation/views.py index 8e346875..bb432458 100644 --- a/reservation/views.py +++ b/reservation/views.py @@ -8,13 +8,37 @@ from django.views.generic import CreateView, DeleteView, TemplateView, UpdateVie from club.models import Club from core.auth.mixins import CanEditMixin -from reservation.forms import RoomCreateForm, RoomUpdateForm -from reservation.models import Room +from core.views import UseFragmentsMixin +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" permission_required = "reservation.view_room" + fragments = {"add_slot_fragment": ReservationFragment} class RoomCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView):