Bugzilla – Attachment 185469 Details for
Bug 40656
bookings/list.tt needs to be refactored
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 40656: (follow-up) Create reusable additional filters library for DataTables
Bug-40656-follow-up-Create-reusable-additional-fil.patch (text/plain), 18.67 KB, created by
Paul Derscheid
on 2025-08-15 15:24:04 UTC
(
hide
)
Description:
Bug 40656: (follow-up) Create reusable additional filters library for DataTables
Filename:
MIME Type:
Creator:
Paul Derscheid
Created:
2025-08-15 15:24:04 UTC
Size:
18.67 KB
patch
obsolete
>From 8ad2e296a2120f1622838f45e14ff81d798bf24a Mon Sep 17 00:00:00 2001 >From: Paul Derscheid <paul.derscheid@lmscloud.de> >Date: Fri, 15 Aug 2025 15:16:45 +0000 >Subject: [PATCH] Bug 40656: (follow-up) Create reusable additional filters > library for DataTables > >This follow-up patch extracts the filter functionality into a reusable library: >- Create AdditionalFilters.js library with chainable API for filter >functionality >- Rename table-filters.inc to additional-filters.inc for clarity >- Refactor to use explicit element IDs instead of interpolation for >better greppability (filter-expired vs filter-${id}) >- Include proper cleanup methods (destroy) for memory management > >The library provides a fluent API: >AdditionalFilters.init(['filter-expired', 'filter-cancelled']) > .onChange((filters, { anyFiltersApplied }) => { ... }) > .build({ status: ({ filters, isNotApplied }) => ... }); > >Test plan remains identical except for applying both patches on step 1. >--- > .../prog/en/includes/additional-filters.inc | 12 + > .../prog/en/includes/table-filters.inc | 30 --- > .../prog/en/modules/bookings/list.tt | 75 +++--- > .../prog/js/additional-filters.js | 230 ++++++++++++++++++ > 4 files changed, 270 insertions(+), 77 deletions(-) > create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/additional-filters.inc > delete mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/table-filters.inc > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/additional-filters.js > >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/additional-filters.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/additional-filters.inc >new file mode 100644 >index 00000000000..5bf06dfc7b3 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/additional-filters.inc >@@ -0,0 +1,12 @@ >+[% IF filters && filters.size > 0 %] >+ [% filter_class = filter_class || 'filters' %] >+ >+ <fieldset class="action filters filters-[% filter_class | html %] d-flex gap-2"> >+ [% FOREACH filter IN filters %] >+ <a id="[% filter.id | html %]" data-filter="[% filter.id | html %]" [% IF !filter.defined('active') || filter.active %]data-filtered[% END %]> >+ <span><i class="fa fa-bars"></i> [% filter.label_show | html %]</span> >+ <span><i class="fa fa-filter"></i> [% filter.label_hide | html %]</span> >+ </a> >+ [% END %] >+ </fieldset> >+[% END %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/table-filters.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/table-filters.inc >deleted file mode 100644 >index 714ce3c1d78..00000000000 >--- a/koha-tmpl/intranet-tmpl/prog/en/includes/table-filters.inc >+++ /dev/null >@@ -1,30 +0,0 @@ >-[%# TABLE FILTER CONTROLS USAGE %] >-[%# [ INCLUDE 'table-filters.inc' %] >-[%# filters = [ %] >-[%# { id = 'expired', label_show = t('Show expired'), label_hide = t('Hide expired') }, %] >-[%# { id = 'cancelled', label_show = t('Show cancelled'), label_hide = t('Hide cancelled') }, %] >-[%# { id = 'completed', label_show = t('Show completed'), label_hide = t('Hide completed') } %] >-[%# ] %] >-[%# filter_class = 'bookings' # Optional, defaults to 'filters' %] >-[%# ] %] >-[%# %] >-[%# Parameters: %] >-[%# - filters: Array of filter objects with: %] >-[%# - id: Unique identifier for the filter %] >-[%# - label_show: Text when filter shows items %] >-[%# - label_hide: Text when filter hides items %] >-[%# - active: Boolean - filter starts active (optional, defaults to true) %] >-[%# - filter_class: CSS class prefix (optional, defaults to 'filters') %] >- >-[% IF filters && filters.size > 0 %] >- [% filter_class = filter_class || 'filters' %] >- >- <fieldset class="action filters filters-[% filter_class | html %] d-flex gap-2"> >- [% FOREACH filter IN filters %] >- <a id="filter-[% filter.id | html %]" data-filter="[% filter.id | html %]" [% IF !filter.defined('active') || filter.active %]data-filtered[% END %]> >- <span><i class="fa fa-bars"></i> [% filter.label_show | html %]</span> >- <span><i class="fa fa-filter"></i> [% filter.label_hide | html %]</span> >- </a> >- [% END %] >- </fieldset> >-[% END %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt >index f3341d5b714..16373710754 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt >@@ -42,10 +42,10 @@ > <div class="page-section" id="bookings-timeline"></div> > <div class="page-section"> > [% >- INCLUDE 'table-filters.inc' >+ INCLUDE 'additional-filters.inc' > filters = [ >- { id = 'expired', label_show = t('Include expired'), label_hide = t('Exclude expired') }, >- { id = 'cancelled', label_show = t('Include cancelled'), label_hide = t('Exclude cancelled') }, >+ { id = 'filter-expired', label_show = t('Include expired'), label_hide = t('Exclude expired') }, >+ { id = 'filter-cancelled', label_show = t('Include cancelled'), label_hide = t('Exclude cancelled') }, > ] > filter_class = 'bookings' > %] >@@ -74,6 +74,7 @@ > [% Asset.js("js/modals/place_booking.js") | $raw %] > [% Asset.js("js/cancel_booking_modal.js") | $raw %] > [% Asset.js("js/combobox.js") | $raw %] >+ [% Asset.js("js/additional-filters.js") | $raw %] > <script> > var cancel_success = 0; > var update_success = 0; >@@ -209,52 +210,32 @@ > } > ); > >- const filters = ["expired", "cancelled"].reduce((acc, filter) => { >- acc[filter] = document.getElementById(`filter-${filter}`); >- return acc; >- }, {}); >- >- Object.values(filters).forEach(filter => { >- filter.addEventListener("click", handleFilter); >- }); >- >- const isFilterActive = (filter) => "filtered" in filter.dataset; >- const isShowingItems = (filter) => !isFilterActive(filter); >- const additional_filters = { >- end_date: () => { >- if (isShowingItems(filters.expired)) { >- let today = new Date(); >- return { ">=": today.toISOString() }; >- } >- }, >- status: () => { >- const defaults = ["new", "pending", "active"]; >- const filtered = [...defaults]; >- if (isShowingItems(filters.cancelled)) { >- filtered.push("cancelled"); >+ const additional_filters = AdditionalFilters.init(["filter-expired", "filter-cancelled"]) >+ .onChange((filters, { anyFiltersApplied }) => { >+ bookings_table.DataTable().ajax.reload(() => { >+ bookings_table >+ .DataTable() >+ .column("status:name") >+ .visible(anyFiltersApplied, false); >+ }); >+ }) >+ .build({ >+ end_date: ({ filters, isNotApplied }) => { >+ if (isNotApplied(filters['filter-expired'])) { >+ let today = new Date(); >+ return { ">=": today.toISOString() }; >+ } >+ }, >+ status: ({ filters, isNotApplied }) => { >+ const defaults = ["new", "pending", "active"]; >+ const filtered = [...defaults]; >+ if (isNotApplied(filters['filter-cancelled'])) { >+ filtered.push("cancelled"); >+ } >+ return { "-in": filtered }; > } >- return { "-in": filtered }; >- }, >- }; >- >- function handleFilter(e) { >- const target = e.target; >- const anchor = target.closest("a"); >- >- if (isFilterActive(anchor)) { >- delete anchor.dataset.filtered; >- } else { >- anchor.dataset.filtered = ""; >- } >- >- bookings_table.DataTable().ajax.reload(() => { >- const anyFiltersActive = Object.values(filters).some(isShowingItems); >- bookings_table >- .DataTable() >- .column("status:name") >- .visible(anyFiltersActive, false); > }); >- } >+ > > var bookings_table_url = "/api/v1/biblios/%s/bookings".format(biblionumber); > bookings_table = $('#bookings_table').kohaTable({ >diff --git a/koha-tmpl/intranet-tmpl/prog/js/additional-filters.js b/koha-tmpl/intranet-tmpl/prog/js/additional-filters.js >new file mode 100644 >index 00000000000..bcb5fc926ad >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/additional-filters.js >@@ -0,0 +1,230 @@ >+/** >+ * Additional filters library for Koha DataTables >+ * >+ * Provides boolean data attribute-based filter controls that integrate >+ * seamlessly with kohaTable's additional_filters parameter. >+ * >+ * Template Usage: >+ * [% INCLUDE 'additional-filters.inc' >+ * filters = [ >+ * { id = 'filter-expired', label_show = t('Include expired'), label_hide = t('Exclude expired') }, >+ * { id = 'filter-cancelled', label_show = t('Include cancelled'), label_hide = t('Exclude cancelled') }, >+ * ] >+ * filter_class = 'bookings' # Optional, defaults to 'filters' >+ * %] >+ * >+ * JavaScript Usage: >+ * @example >+ * const additional_filters = AdditionalFilters.init(['filter-expired', 'filter-cancelled']) >+ * .onChange((filters, { anyFiltersApplied }) => { >+ * table.column('status').visible(anyFiltersApplied); >+ * }) >+ * .build({ >+ * status: ({ filters, isNotApplied }) => >+ * isNotApplied(filters['filter-expired']) ? { '!=': 'expired' } : undefined >+ * }); >+ */ >+ >+window.AdditionalFilters = { >+ /** >+ * Initialize filter controls and attach event listeners >+ * @param {string[]|Object} filterIds - Array of full element IDs or options object >+ * @param {Function} [onFilterChange] - Callback when filters change >+ * @param {Object} [options] - Configuration options >+ * @param {string} [options.event='click'] - Event type to listen for >+ * @param {string} [options.attribute='filtered'] - Data attribute name >+ * @param {string} [options.closest='a'] - Element selector for event delegation >+ * @param {boolean} [options.strict=true] - Log warnings for missing elements >+ * @returns {AdditionalFiltersAPI} Chainable API object >+ */ >+ init: function (filterIds, onFilterChange, options = {}) { >+ if (typeof filterIds === "object" && !Array.isArray(filterIds)) { >+ options = filterIds; >+ filterIds = options.filterIds || []; >+ onFilterChange = options.onFilterChange || onFilterChange; >+ } >+ >+ const config = { >+ event: options.event || "click", >+ attribute: options.attribute || "filtered", >+ closest: options.closest || "a", >+ ...options, >+ }; >+ >+ const filters = {}; >+ >+ const isApplied = filter => >+ filter?.hasAttribute(`data-${config.attribute}`); >+ const isNotApplied = filter => !isApplied(filter); >+ >+ function attachFilter(elementId) { >+ const element = document.getElementById(elementId); >+ if (element) { >+ filters[elementId] = element; >+ element.addEventListener(config.event, handleFilter); >+ } else if (config.strict !== false) { >+ console.debug( >+ `AdditionalFilters: Element not found with ID '${elementId}'` >+ ); >+ } >+ } >+ >+ function handleFilter(e) { >+ const target = e.target; >+ const filter = target.closest(config.closest); >+ if (!filter) return; >+ >+ if (isApplied(filter)) { >+ filter.removeAttribute(`data-${config.attribute}`); >+ } else { >+ filter.setAttribute(`data-${config.attribute}`, ""); >+ } >+ >+ if (changeCallback) { >+ changeCallback(filters, { >+ anyFiltersApplied: Object.values(filters).some(isApplied), >+ anyFiltersNotApplied: >+ Object.values(filters).some(isNotApplied), >+ isApplied, >+ isNotApplied, >+ }); >+ } >+ } >+ >+ filterIds.forEach(attachFilter); >+ >+ let filterDefinitions = {}; >+ let changeCallback = onFilterChange; >+ >+ const api = { >+ filters: filters, >+ isApplied: isApplied, >+ isNotApplied: isNotApplied, >+ config: config, >+ >+ /** >+ * Re-scan DOM for missing filter elements >+ * @returns {AdditionalFiltersAPI} Chainable API >+ */ >+ refresh: function () { >+ filterIds.forEach(filterId => { >+ if (!filters[filterId]) { >+ attachFilter(filterId); >+ } >+ }); >+ return api; >+ }, >+ >+ /** >+ * Set or update the filter change callback >+ * @param {Function} callback - Called when filters change >+ * @param {Object} callback.filters - Filter element map >+ * @param {Object} callback.helpers - Helper functions and state >+ * @param {boolean} callback.helpers.anyFiltersApplied - True if any filter is applied >+ * @param {boolean} callback.helpers.anyFiltersNotApplied - True if any filter is not applied >+ * @param {Function} callback.helpers.isApplied - Check if filter is applied >+ * @param {Function} callback.helpers.isNotApplied - Check if filter is not applied >+ * @returns {AdditionalFiltersAPI} Chainable API >+ */ >+ onChange: function (callback) { >+ changeCallback = callback; >+ return api; >+ }, >+ >+ /** >+ * Clean up event listeners and references >+ * @returns {void} >+ */ >+ destroy: function () { >+ Object.values(filters).forEach(filter => { >+ if (filter) { >+ filter.removeEventListener(config.event, handleFilter); >+ } >+ }); >+ Object.keys(filters).forEach(key => delete filters[key]); >+ changeCallback = null; >+ }, >+ >+ /** >+ * Add filter definitions for API parameters >+ * @param {Object} definitions - Map of API parameters to generator functions >+ * @returns {AdditionalFiltersAPI} Chainable API >+ */ >+ withFilters: function (definitions) { >+ filterDefinitions = { ...filterDefinitions, ...definitions }; >+ return api; >+ }, >+ >+ /** >+ * Generate additional_filters object for kohaTable >+ * @param {Object} [definitions] - Filter definitions to use >+ * @returns {Object} additional_filters object for kohaTable >+ */ >+ getAdditionalFilters: function (definitions) { >+ const filtersToUse = definitions || filterDefinitions; >+ const additionalFilters = {}; >+ >+ for (const [apiParam, generator] of Object.entries( >+ filtersToUse >+ )) { >+ additionalFilters[apiParam] = () => { >+ return generator({ >+ filters, >+ isApplied, >+ isNotApplied, >+ }); >+ }; >+ } >+ >+ return additionalFilters; >+ }, >+ >+ /** >+ * Set filter definitions and return additional_filters object >+ * @param {Object} [definitions] - Filter definitions >+ * @returns {Object} additional_filters object for kohaTable >+ */ >+ build: function (definitions) { >+ if (definitions) { >+ filterDefinitions = { >+ ...filterDefinitions, >+ ...definitions, >+ }; >+ } >+ return this.getAdditionalFilters(); >+ }, >+ }; >+ >+ return api; >+ }, >+ >+ /** >+ * Initialize filters when DOM is ready >+ * @param {string[]|Object} filterIds - Array of full element IDs or options object >+ * @param {Function} [onFilterChange] - Callback when filters change >+ * @param {Object} [options] - Configuration options >+ * @param {boolean} [options.allowEmpty] - Resolve even if no elements found >+ * @returns {Promise<AdditionalFiltersAPI>} Promise resolving to API object >+ */ >+ ready: function (filterIds, onFilterChange, options = {}) { >+ return new Promise(resolve => { >+ const tryInit = () => { >+ const helper = this.init(filterIds, onFilterChange, options); >+ if ( >+ Object.keys(helper.filters).length > 0 || >+ options.allowEmpty >+ ) { >+ resolve(helper); >+ } else { >+ setTimeout(tryInit, 50); >+ } >+ }; >+ >+ if (document.readyState === "loading") { >+ document.addEventListener("DOMContentLoaded", tryInit); >+ } else { >+ tryInit(); >+ } >+ }); >+ }, >+}; >-- >2.39.5
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 40656
:
185468
| 185469 |
185470