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.

Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
(cherry picked from commit e358823091)
Signed-off-by: Paul Derscheid <paul.derscheid@lmscloud.de>
This commit is contained in:
Paul Derscheid 2025-08-15 15:16:45 +00:00
parent bdb6a1a0ed
commit 563e93225b
No known key found for this signature in database
GPG Key ID: C25F9FD44CED595B
4 changed files with 270 additions and 77 deletions

View File

@ -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 %]

View File

@ -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 %]

View File

@ -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() };
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 };
}
},
status: () => {
const defaults = ["new", "pending", "active"];
const filtered = [...defaults];
if (isShowingItems(filters.cancelled)) {
filtered.push("cancelled");
}
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({

View File

@ -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();
}
});
},
};