View | Details | Raw Unified | Return to bug 40656
Collapse All | Expand All

(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/additional-filters.inc (+12 lines)
Line 0 Link Here
1
[% IF filters && filters.size > 0 %]
2
    [% filter_class = filter_class || 'filters' %]
3
4
    <fieldset class="action filters filters-[% filter_class | html %] d-flex gap-2">
5
        [% FOREACH filter IN filters %]
6
            <a id="[% filter.id | html %]" data-filter="[% filter.id | html %]" [% IF !filter.defined('active') || filter.active %]data-filtered[% END %]>
7
                <span><i class="fa fa-bars"></i> [% filter.label_show | html %]</span>
8
                <span><i class="fa fa-filter"></i> [% filter.label_hide | html %]</span>
9
            </a>
10
        [% END %]
11
    </fieldset>
12
[% END %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/table-filters.inc (-30 lines)
Lines 1-30 Link Here
1
[%# TABLE FILTER CONTROLS USAGE                                                                          %]
2
[%#    [ INCLUDE 'table-filters.inc'                                                                     %]
3
[%#        filters = [                                                                                   %]
4
[%#            { id = 'expired',   label_show = t('Show expired'),   label_hide = t('Hide expired') },   %]
5
[%#            { id = 'cancelled', label_show = t('Show cancelled'), label_hide = t('Hide cancelled') }, %]
6
[%#            { id = 'completed', label_show = t('Show completed'), label_hide = t('Hide completed') }  %]
7
[%#        ]                                                                                             %]
8
[%#        filter_class = 'bookings'  # Optional, defaults to 'filters'                                  %]
9
[%#    ]                                                                                                 %]
10
[%#                                                                                                      %]
11
[%# Parameters:                                                                                          %]
12
[%#   - filters: Array of filter objects with:                                                           %]
13
[%#       - id: Unique identifier for the filter                                                         %]
14
[%#       - label_show: Text when filter shows items                                                     %]
15
[%#       - label_hide: Text when filter hides items                                                     %]
16
[%#       - active: Boolean - filter starts active (optional, defaults to true)                          %]
17
[%#   - filter_class: CSS class prefix (optional, defaults to 'filters')                                 %]
18
19
[% IF filters && filters.size > 0 %]
20
    [% filter_class = filter_class || 'filters' %]
21
22
    <fieldset class="action filters filters-[% filter_class | html %] d-flex gap-2">
23
        [% FOREACH filter IN filters %]
24
            <a id="filter-[% filter.id | html %]" data-filter="[% filter.id | html %]" [% IF !filter.defined('active') || filter.active %]data-filtered[% END %]>
25
                <span><i class="fa fa-bars"></i> [% filter.label_show | html %]</span>
26
                <span><i class="fa fa-filter"></i> [% filter.label_hide | html %]</span>
27
            </a>
28
        [% END %]
29
    </fieldset>
30
[% END %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt (-47 / +28 lines)
Lines 42-51 Link Here
42
        <div class="page-section" id="bookings-timeline"></div>
42
        <div class="page-section" id="bookings-timeline"></div>
43
        <div class="page-section">
43
        <div class="page-section">
44
            [%
44
            [%
45
                INCLUDE 'table-filters.inc'
45
                INCLUDE 'additional-filters.inc'
46
                filters = [
46
                filters = [
47
                    { id = 'expired', label_show = t('Include expired'), label_hide = t('Exclude expired') },
47
                    { id = 'filter-expired', label_show = t('Include expired'), label_hide = t('Exclude expired') },
48
                    { id = 'cancelled', label_show = t('Include cancelled'), label_hide = t('Exclude cancelled') },
48
                    { id = 'filter-cancelled', label_show = t('Include cancelled'), label_hide = t('Exclude cancelled') },
49
                ]
49
                ]
50
                filter_class = 'bookings'
50
                filter_class = 'bookings'
51
            %]
51
            %]
Lines 74-79 Link Here
74
    [% Asset.js("js/modals/place_booking.js") | $raw %]
74
    [% Asset.js("js/modals/place_booking.js") | $raw %]
75
    [% Asset.js("js/cancel_booking_modal.js") | $raw %]
75
    [% Asset.js("js/cancel_booking_modal.js") | $raw %]
76
    [% Asset.js("js/combobox.js") | $raw %]
76
    [% Asset.js("js/combobox.js") | $raw %]
77
    [% Asset.js("js/additional-filters.js") | $raw %]
77
    <script>
78
    <script>
78
        var cancel_success = 0;
79
        var cancel_success = 0;
79
        var update_success = 0;
80
        var update_success = 0;
Lines 209-260 Link Here
209
                }
210
                }
210
            );
211
            );
211
212
212
            const filters = ["expired", "cancelled"].reduce((acc, filter) => {
213
            const additional_filters = AdditionalFilters.init(["filter-expired", "filter-cancelled"])
213
                acc[filter] = document.getElementById(`filter-${filter}`);
214
                .onChange((filters, { anyFiltersApplied }) => {
214
                return acc;
215
                    bookings_table.DataTable().ajax.reload(() => {
215
            }, {});
216
                        bookings_table
216
217
                            .DataTable()
217
            Object.values(filters).forEach(filter => {
218
                            .column("status:name")
218
                filter.addEventListener("click", handleFilter);
219
                            .visible(anyFiltersApplied, false);
219
            });
220
                    });
220
221
                })
221
            const isFilterActive = (filter) => "filtered" in filter.dataset;
222
                .build({
222
            const isShowingItems = (filter) => !isFilterActive(filter);
223
                    end_date: ({ filters, isNotApplied }) => {
223
            const additional_filters = {
224
                        if (isNotApplied(filters['filter-expired'])) {
224
                end_date: () => {
225
                            let today = new Date();
225
                    if (isShowingItems(filters.expired)) {
226
                            return { ">=": today.toISOString() };
226
                        let today = new Date();
227
                        }
227
                        return { ">=": today.toISOString() };
228
                    },
228
                    }
229
                    status: ({ filters, isNotApplied }) => {
229
                },
230
                        const defaults = ["new", "pending", "active"];
230
                status: () => {
231
                        const filtered = [...defaults];
231
                    const defaults = ["new", "pending", "active"];
232
                        if (isNotApplied(filters['filter-cancelled'])) {
232
                    const filtered = [...defaults];
233
                            filtered.push("cancelled");
233
                    if (isShowingItems(filters.cancelled)) {
234
                        }
234
                        filtered.push("cancelled");
235
                        return { "-in": filtered };
235
                    }
236
                    }
236
                    return { "-in": filtered };
237
                },
238
            };
239
240
            function handleFilter(e) {
241
                const target = e.target;
242
                const anchor = target.closest("a");
243
244
                if (isFilterActive(anchor)) {
245
                    delete anchor.dataset.filtered;
246
                } else {
247
                    anchor.dataset.filtered = "";
248
                }
249
250
                bookings_table.DataTable().ajax.reload(() => {
251
                    const anyFiltersActive = Object.values(filters).some(isShowingItems);
252
                    bookings_table
253
                        .DataTable()
254
                        .column("status:name")
255
                        .visible(anyFiltersActive, false);
256
                });
237
                });
257
            }
238
258
239
259
            var bookings_table_url = "/api/v1/biblios/%s/bookings".format(biblionumber);
240
            var bookings_table_url = "/api/v1/biblios/%s/bookings".format(biblionumber);
260
            bookings_table = $('#bookings_table').kohaTable({
241
            bookings_table = $('#bookings_table').kohaTable({
(-)a/koha-tmpl/intranet-tmpl/prog/js/additional-filters.js (-1 / +230 lines)
Line 0 Link Here
0
- 
1
/**
2
 * Additional filters library for Koha DataTables
3
 *
4
 * Provides boolean data attribute-based filter controls that integrate
5
 * seamlessly with kohaTable's additional_filters parameter.
6
 *
7
 * Template Usage:
8
 * [% INCLUDE 'additional-filters.inc'
9
 *     filters = [
10
 *         { id = 'filter-expired', label_show = t('Include expired'), label_hide = t('Exclude expired') },
11
 *         { id = 'filter-cancelled', label_show = t('Include cancelled'), label_hide = t('Exclude cancelled') },
12
 *     ]
13
 *     filter_class = 'bookings'  # Optional, defaults to 'filters'
14
 * %]
15
 *
16
 * JavaScript Usage:
17
 * @example
18
 * const additional_filters = AdditionalFilters.init(['filter-expired', 'filter-cancelled'])
19
 *   .onChange((filters, { anyFiltersApplied }) => {
20
 *     table.column('status').visible(anyFiltersApplied);
21
 *   })
22
 *   .build({
23
 *     status: ({ filters, isNotApplied }) =>
24
 *       isNotApplied(filters['filter-expired']) ? { '!=': 'expired' } : undefined
25
 *   });
26
 */
27
28
window.AdditionalFilters = {
29
    /**
30
     * Initialize filter controls and attach event listeners
31
     * @param {string[]|Object} filterIds - Array of full element IDs or options object
32
     * @param {Function} [onFilterChange] - Callback when filters change
33
     * @param {Object} [options] - Configuration options
34
     * @param {string} [options.event='click'] - Event type to listen for
35
     * @param {string} [options.attribute='filtered'] - Data attribute name
36
     * @param {string} [options.closest='a'] - Element selector for event delegation
37
     * @param {boolean} [options.strict=true] - Log warnings for missing elements
38
     * @returns {AdditionalFiltersAPI} Chainable API object
39
     */
40
    init: function (filterIds, onFilterChange, options = {}) {
41
        if (typeof filterIds === "object" && !Array.isArray(filterIds)) {
42
            options = filterIds;
43
            filterIds = options.filterIds || [];
44
            onFilterChange = options.onFilterChange || onFilterChange;
45
        }
46
47
        const config = {
48
            event: options.event || "click",
49
            attribute: options.attribute || "filtered",
50
            closest: options.closest || "a",
51
            ...options,
52
        };
53
54
        const filters = {};
55
56
        const isApplied = filter =>
57
            filter?.hasAttribute(`data-${config.attribute}`);
58
        const isNotApplied = filter => !isApplied(filter);
59
60
        function attachFilter(elementId) {
61
            const element = document.getElementById(elementId);
62
            if (element) {
63
                filters[elementId] = element;
64
                element.addEventListener(config.event, handleFilter);
65
            } else if (config.strict !== false) {
66
                console.debug(
67
                    `AdditionalFilters: Element not found with ID '${elementId}'`
68
                );
69
            }
70
        }
71
72
        function handleFilter(e) {
73
            const target = e.target;
74
            const filter = target.closest(config.closest);
75
            if (!filter) return;
76
77
            if (isApplied(filter)) {
78
                filter.removeAttribute(`data-${config.attribute}`);
79
            } else {
80
                filter.setAttribute(`data-${config.attribute}`, "");
81
            }
82
83
            if (changeCallback) {
84
                changeCallback(filters, {
85
                    anyFiltersApplied: Object.values(filters).some(isApplied),
86
                    anyFiltersNotApplied:
87
                        Object.values(filters).some(isNotApplied),
88
                    isApplied,
89
                    isNotApplied,
90
                });
91
            }
92
        }
93
94
        filterIds.forEach(attachFilter);
95
96
        let filterDefinitions = {};
97
        let changeCallback = onFilterChange;
98
99
        const api = {
100
            filters: filters,
101
            isApplied: isApplied,
102
            isNotApplied: isNotApplied,
103
            config: config,
104
105
            /**
106
             * Re-scan DOM for missing filter elements
107
             * @returns {AdditionalFiltersAPI} Chainable API
108
             */
109
            refresh: function () {
110
                filterIds.forEach(filterId => {
111
                    if (!filters[filterId]) {
112
                        attachFilter(filterId);
113
                    }
114
                });
115
                return api;
116
            },
117
118
            /**
119
             * Set or update the filter change callback
120
             * @param {Function} callback - Called when filters change
121
             * @param {Object} callback.filters - Filter element map
122
             * @param {Object} callback.helpers - Helper functions and state
123
             * @param {boolean} callback.helpers.anyFiltersApplied - True if any filter is applied
124
             * @param {boolean} callback.helpers.anyFiltersNotApplied - True if any filter is not applied
125
             * @param {Function} callback.helpers.isApplied - Check if filter is applied
126
             * @param {Function} callback.helpers.isNotApplied - Check if filter is not applied
127
             * @returns {AdditionalFiltersAPI} Chainable API
128
             */
129
            onChange: function (callback) {
130
                changeCallback = callback;
131
                return api;
132
            },
133
134
            /**
135
             * Clean up event listeners and references
136
             * @returns {void}
137
             */
138
            destroy: function () {
139
                Object.values(filters).forEach(filter => {
140
                    if (filter) {
141
                        filter.removeEventListener(config.event, handleFilter);
142
                    }
143
                });
144
                Object.keys(filters).forEach(key => delete filters[key]);
145
                changeCallback = null;
146
            },
147
148
            /**
149
             * Add filter definitions for API parameters
150
             * @param {Object} definitions - Map of API parameters to generator functions
151
             * @returns {AdditionalFiltersAPI} Chainable API
152
             */
153
            withFilters: function (definitions) {
154
                filterDefinitions = { ...filterDefinitions, ...definitions };
155
                return api;
156
            },
157
158
            /**
159
             * Generate additional_filters object for kohaTable
160
             * @param {Object} [definitions] - Filter definitions to use
161
             * @returns {Object} additional_filters object for kohaTable
162
             */
163
            getAdditionalFilters: function (definitions) {
164
                const filtersToUse = definitions || filterDefinitions;
165
                const additionalFilters = {};
166
167
                for (const [apiParam, generator] of Object.entries(
168
                    filtersToUse
169
                )) {
170
                    additionalFilters[apiParam] = () => {
171
                        return generator({
172
                            filters,
173
                            isApplied,
174
                            isNotApplied,
175
                        });
176
                    };
177
                }
178
179
                return additionalFilters;
180
            },
181
182
            /**
183
             * Set filter definitions and return additional_filters object
184
             * @param {Object} [definitions] - Filter definitions
185
             * @returns {Object} additional_filters object for kohaTable
186
             */
187
            build: function (definitions) {
188
                if (definitions) {
189
                    filterDefinitions = {
190
                        ...filterDefinitions,
191
                        ...definitions,
192
                    };
193
                }
194
                return this.getAdditionalFilters();
195
            },
196
        };
197
198
        return api;
199
    },
200
201
    /**
202
     * Initialize filters when DOM is ready
203
     * @param {string[]|Object} filterIds - Array of full element IDs or options object
204
     * @param {Function} [onFilterChange] - Callback when filters change
205
     * @param {Object} [options] - Configuration options
206
     * @param {boolean} [options.allowEmpty] - Resolve even if no elements found
207
     * @returns {Promise<AdditionalFiltersAPI>} Promise resolving to API object
208
     */
209
    ready: function (filterIds, onFilterChange, options = {}) {
210
        return new Promise(resolve => {
211
            const tryInit = () => {
212
                const helper = this.init(filterIds, onFilterChange, options);
213
                if (
214
                    Object.keys(helper.filters).length > 0 ||
215
                    options.allowEmpty
216
                ) {
217
                    resolve(helper);
218
                } else {
219
                    setTimeout(tryInit, 50);
220
                }
221
            };
222
223
            if (document.readyState === "loading") {
224
                document.addEventListener("DOMContentLoaded", tryInit);
225
            } else {
226
                tryInit();
227
            }
228
        });
229
    },
230
};

Return to bug 40656