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:
parent
bdb6a1a0ed
commit
563e93225b
|
|
@ -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 %]
|
||||
|
|
@ -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 %]
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue