Bugzilla – Attachment 188551 Details for
Bug 41129
Migrate place_booking.js to a Vue island.
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 41129: Use Bootstrap 5 Modal API for booking modal
Bug-41129-Use-Bootstrap-5-Modal-API-for-booking-mo.patch (text/plain), 16.09 KB, created by
Paul Derscheid
on 2025-10-29 14:35:48 UTC
(
hide
)
Description:
Bug 41129: Use Bootstrap 5 Modal API for booking modal
Filename:
MIME Type:
Creator:
Paul Derscheid
Created:
2025-10-29 14:35:48 UTC
Size:
16.09 KB
patch
obsolete
>From dfe1aed3b7795ff161f27b89c1e3612e9853e4bb Mon Sep 17 00:00:00 2001 >From: Paul Derscheid <paul.derscheid@lmscloud.de> >Date: Wed, 29 Oct 2025 15:34:20 +0100 >Subject: [PATCH] Bug 41129: Use Bootstrap 5 Modal API for booking modal > >- Use Bootstrap Modal API for show/hide/dispose lifecycle >- Replace custom modal classes with Bootstrap native classes >- Remove custom backdrop CSS >- Remove modal-scroll.mjs polyfill >- Unify two watchers on props.open into single watcher >- Add focus management to prevent aria-hidden warnings >--- > .../vue/components/Bookings/BookingModal.vue | 253 ++++++++---------- > .../Bookings/lib/adapters/modal-scroll.mjs | 36 --- > 2 files changed, 107 insertions(+), 182 deletions(-) > delete mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs > >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue >index 50dc0b78ec1..a9df48d6bc6 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue >@@ -1,29 +1,27 @@ > <template> > <div >- v-if="modalState.isOpen" >- class="modal show booking-modal-backdrop" >+ ref="modalElement" >+ class="modal fade" > tabindex="-1" > role="dialog" > > > <div >- class="modal-dialog booking-modal-window booking-modal" >+ class="modal-dialog modal-lg" > role="document" > > > <div class="modal-content"> >- <div class="booking-modal-header"> >- <h5 class="booking-modal-title"> >+ <div class="modal-header"> >+ <h1 class="modal-title fs-5"> > {{ modalTitle }} >- </h5> >+ </h1> > <button > type="button" >- class="booking-modal-close" >+ class="btn-close" > aria-label="Close" > @click="handleClose" >- > >- <span aria-hidden="true">×</span> >- </button> >+ ></button> > </div> >- <div class="modal-body booking-modal-body"> >+ <div class="modal-body"> > <form > id="form-booking" > :action="submitUrl" >@@ -135,8 +133,11 @@ import { > computed, > reactive, > watch, >+ ref, >+ onMounted, > onUnmounted, > } from "vue"; >+import { Modal } from "bootstrap"; > import BookingPatronStep from "./BookingPatronStep.vue"; > import BookingDetailsStep from "./BookingDetailsStep.vue"; > import BookingPeriodStep from "./BookingPeriodStep.vue"; >@@ -150,7 +151,6 @@ import { > import { useBookingStore } from "../../stores/bookings"; > import { storeToRefs } from "pinia"; > import { updateExternalDependents } from "./lib/adapters/external-dependents.mjs"; >-import { enableBodyScroll, disableBodyScroll } from "./lib/adapters/modal-scroll.mjs"; > import { appendHiddenInputs } from "./lib/adapters/form.mjs"; > import { calculateStepNumbers } from "./lib/ui/steps.mjs"; > import { useBookingValidation } from "./composables/useBookingValidation.mjs"; >@@ -217,6 +217,9 @@ export default { > setup(props, { emit }) { > const store = useBookingStore(); > >+ const modalElement = ref(null); >+ let bsModal = null; >+ > // Properly destructure reactive store state using storeToRefs > const { > bookingId, >@@ -415,83 +418,102 @@ export default { > canSubmit: isSubmitReady.value, > })); > >+ onMounted(() => { >+ if (modalElement.value) { >+ bsModal = new Modal(modalElement.value, { >+ backdrop: 'static', >+ keyboard: false >+ }); >+ >+ // Note: We don't emit "close" here because the close flow is: >+ // 1. Component emits "close" â 2. Parent sets props.open = false >+ // 3. Watcher calls bsModal.hide() â 4. This event fires for cleanup >+ modalElement.value.addEventListener('hidden.bs.modal', () => { >+ resetModalState(); >+ }); >+ } >+ }); >+ >+ onUnmounted(() => { >+ if (bsModal) { >+ bsModal.dispose(); >+ } >+ }); >+ > // Watchers: synchronize open state, orchestrate initial data load, > // push availability to store, fetch rules/pickup locations, and keep > // UX guidance/errors fresh while inputs change. > >- // Sync internal modal state with the `open` prop and reset inputs when >- // opening to ensure a clean form each time. >+ // Controls Bootstrap modal visibility and orchestrates data loading on open. >+ // Handles async fetch ordering and proper blur/hide sequence on close. > watch( > () => props.open, >- val => { >- modalState.isOpen = val; >- if (val) { >- resetModalState(); >- } >- } >- ); >+ async (open) => { >+ if (!bsModal || !modalElement.value) return; > >- // Orchestrates initial data loading on modal open. This needs to >- // remain isolated because it handles async fetch ordering, DOM body >- // scroll state and localization preload. >- watch( >- () => modalState.isOpen, >- async open => { > if (open) { >- disableBodyScroll(); >- } else { >- enableBodyScroll(); >- return; >- } >- >- modalState.step = 1; >- const biblionumber = props.biblionumber; >- if (!biblionumber) return; >- >- bookingId.value = props.bookingId; >- >- try { >- // Fetch core data first >- await Promise.all([ >- store.fetchBookableItems(biblionumber), >- store.fetchBookings(biblionumber), >- store.fetchCheckouts(biblionumber), >- ]); >- >- modalState.hasAdditionalFields = false; >- >- // Derive item types after bookable items are loaded >- store.deriveItemTypesFromBookableItems(); >- >- // If editing with patron, fetch patron-specific data >- if (props.patronId) { >- const patron = await store.fetchPatron(props.patronId); >- await store.fetchPickupLocations( >- biblionumber, >- props.patronId >- ); >- >- // Now set patron after data is available >- bookingPatron.value = patron; >- } >- >- // Set other form values after all dependencies are loaded >+ bsModal.show(); >+ resetModalState(); > >- // Normalize itemId type to match bookableItems' item_id type for vue-select strict matching >- bookingItemId.value = (props.itemId != null) ? normalizeIdType(bookableItems.value?.[0]?.item_id, props.itemId) : null; >- if (props.itemtypeId) { >- bookingItemtypeId.value = props.itemtypeId; >+ modalState.step = 1; >+ const biblionumber = props.biblionumber; >+ if (!biblionumber) return; >+ >+ bookingId.value = props.bookingId; >+ >+ try { >+ // Fetch core data first >+ await Promise.all([ >+ store.fetchBookableItems(biblionumber), >+ store.fetchBookings(biblionumber), >+ store.fetchCheckouts(biblionumber), >+ ]); >+ >+ modalState.hasAdditionalFields = false; >+ >+ // Derive item types after bookable items are loaded >+ store.deriveItemTypesFromBookableItems(); >+ >+ // If editing with patron, fetch patron-specific data >+ if (props.patronId) { >+ const patron = await store.fetchPatron(props.patronId); >+ await store.fetchPickupLocations( >+ biblionumber, >+ props.patronId >+ ); >+ >+ // Now set patron after data is available >+ bookingPatron.value = patron; >+ } >+ >+ // Set other form values after all dependencies are loaded >+ >+ // Normalize itemId type to match bookableItems' item_id type for vue-select strict matching >+ bookingItemId.value = (props.itemId != null) ? normalizeIdType(bookableItems.value?.[0]?.item_id, props.itemId) : null; >+ if (props.itemtypeId) { >+ bookingItemtypeId.value = props.itemtypeId; >+ } >+ >+ if (props.startDate && props.endDate) { >+ selectedDateRange.value = [ >+ toISO(props.startDate), >+ toISO(props.endDate), >+ ]; >+ } >+ } catch (error) { >+ console.error("Error initializing booking modal:", error); >+ setError(processApiError(error), "api"); > } >- >- if (props.startDate && props.endDate) { >- selectedDateRange.value = [ >- toISO(props.startDate), >- toISO(props.endDate), >- ]; >+ } else { >+ // Only hide if modal is actually shown (prevents double-hiding) >+ const isShown = modalElement.value.classList.contains('show'); >+ if (isShown) { >+ // Blur active element to prevent aria-hidden warning >+ if (document.activeElement instanceof HTMLElement) { >+ document.activeElement.blur(); >+ } >+ bsModal.hide(); > } >- } catch (error) { >- console.error("Error initializing booking modal:", error); >- setError(processApiError(error), "api"); > } > } > ); >@@ -610,10 +632,7 @@ export default { > } > > function handleClose() { >- modalState.isOpen = false; >- enableBodyScroll(); > emit("close"); >- resetModalState(); > } > > async function handleSubmit(event) { >@@ -662,18 +681,13 @@ export default { > const result = await store.saveOrUpdateBooking(bookingData); > updateExternalDependents(result, bookingPatron.value, !!props.bookingId); > emit("close"); >- resetModalState(); > } catch (errorObj) { > setError(processApiError(errorObj), "api"); > } > } > >- // Cleanup function for proper memory management >- onUnmounted(() => { >- enableBodyScroll(); >- }); >- > return { >+ modalElement, > modalState, > modalTitle, > submitLabel, >@@ -722,18 +736,6 @@ export default { > </script> > > <style> >-.booking-modal-backdrop { >- position: fixed; >- top: 0; >- left: 0; >- width: 100%; >- height: 100%; >- background: rgba(0, 0, 0, 0.5); >- z-index: 1050; >- display: block; >- overflow-y: auto; >-} >- > /* Global variables for external libraries (flatpickr) and cross-block usage */ > :root { > /* Success colors for constraint highlighting */ >@@ -772,24 +774,21 @@ export default { > --booking-border-radius-sm: 0.25rem; > --booking-border-radius-md: 0.5rem; > --booking-border-radius-full: 50%; >-} > >-/* Design System: CSS Custom Properties (First Style Block Only) */ >-.booking-modal-backdrop { >- /* Colors not used in second style block */ >+ /* Additional colors */ > --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%); > --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%); > --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%); > --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%); > >- /* Spacing Scale (first block only) */ >+ /* Spacing Scale */ > --booking-space-sm: 0.25rem; /* 4px */ > --booking-space-md: 0.5rem; /* 8px */ > --booking-space-lg: 1rem; /* 16px */ > --booking-space-xl: 1.5rem; /* 24px */ > --booking-space-2xl: 2rem; /* 32px */ > >- /* Typography Scale (first block only) */ >+ /* Typography Scale */ > --booking-text-sm: 0.8125rem; > --booking-text-base: 1rem; > --booking-text-lg: 1.1rem; >@@ -841,7 +840,7 @@ export default { > } > > /* Modal Layout Components */ >-.booking-modal-window { >+.modal-dialog { > max-height: var(--booking-modal-max-height); > margin: var(--booking-space-lg) auto; > } >@@ -852,45 +851,7 @@ export default { > flex-direction: column; > } > >-.booking-modal-header { >- padding: var(--booking-space-lg); >- border-bottom: var(--booking-border-width) solid var(--booking-neutral-100); >- display: flex; >- align-items: center; >- justify-content: space-between; >- flex-shrink: 0; >-} >- >-.booking-modal-title { >- margin: 0; >- font-size: var(--booking-text-xl); >- font-weight: 600; >-} >- >-.booking-modal-close { >- background: transparent; >- border: none; >- font-size: var(--booking-text-2xl); >- line-height: 1; >- cursor: pointer; >- color: var(--booking-neutral-500); >- opacity: 0.5; >- transition: opacity var(--booking-transition-fast); >- padding: 0; >- margin: 0; >- width: var(--booking-space-2xl); >- height: var(--booking-space-2xl); >- display: flex; >- align-items: center; >- justify-content: center; >-} >- >-.booking-modal-close:hover { >- opacity: 0.75; >-} >- >-.booking-modal-body { >- padding: var(--booking-space-xl); >+.modal-body { > overflow-y: auto; > flex: 1 1 auto; > } >@@ -999,7 +960,7 @@ hr { > } > > /* External Library Integration */ >-.booking-modal-body .vs__selected { >+.modal-body .vs__selected { > font-size: var(--vs-font-size); > line-height: var(--vs-line-height); > } >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs >deleted file mode 100644 >index d27ca655150..00000000000 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs >+++ /dev/null >@@ -1,36 +0,0 @@ >-/** >- * Modal scroll helpers shared across components. >- * Uses a window-scoped counter to manage body scroll lock for nested modals. >- */ >- >-/** >- * Enable body scroll when the last modal closes. >- */ >-export function enableBodyScroll() { >- const count = Number(window["kohaModalCount"] ?? 0); >- window["kohaModalCount"] = Math.max(0, count - 1); >- >- if ((window["kohaModalCount"] ?? 0) === 0) { >- document.body.classList.remove("modal-open"); >- if (document.body.style.paddingRight) { >- document.body.style.paddingRight = ""; >- } >- } >-} >- >-/** >- * Disable body scroll while a modal is open. >- */ >-export function disableBodyScroll() { >- const current = Number(window["kohaModalCount"] ?? 0); >- window["kohaModalCount"] = current + 1; >- >- if (!document.body.classList.contains("modal-open")) { >- const scrollbarWidth = >- window.innerWidth - document.documentElement.clientWidth; >- if (scrollbarWidth > 0) { >- document.body.style.paddingRight = `${scrollbarWidth}px`; >- } >- document.body.classList.add("modal-open"); >- } >-} >-- >2.51.2
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 41129
:
188546
| 188551