Skip to content

Commit

Permalink
paginate local data when displaying icons selector, fix glpi-project#…
Browse files Browse the repository at this point in the history
  • Loading branch information
orthagh authored and cedric-anne committed Aug 1, 2024
1 parent b890b38 commit 52eb093
Show file tree
Hide file tree
Showing 6 changed files with 189 additions and 86 deletions.
4 changes: 4 additions & 0 deletions .webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ let config = {
],
use: ['script-loader', 'strip-sourcemap-loader'],
},
{
test: /\.json$/,
type: 'json'
},
{
// Test for a polyfill (or any file) and it won't be included in your
// bundle
Expand Down
167 changes: 132 additions & 35 deletions js/modules/Form/WebIconSelector.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* http://glpi-project.org
*
* @copyright 2015-2024 Teclib' and contributors.
* @copyright 2003-2014 by the INDEPNET Development Team.
* @licence https://www.gnu.org/licenses/gpl-3.0.html
*
* ---------------------------------------------------------------------
Expand All @@ -31,6 +30,8 @@
* ---------------------------------------------------------------------
*/

import '../../../public/lib/tablericons-definitions.js';

/**
* Web icon selector component.
* This class can handle both Tabler and FontAwesome icons.
Expand All @@ -41,13 +42,9 @@ export class WebIconSelector {

/**
* @param {HTMLSelectElement} selectElement The select element to use.
* @param {array} icon_sets The icon sets to use.
* Valid icon sets are 'ti' (Tabler) and 'fa' (FontAwesome).
* The tabler icon set is the preferred one.
*/
constructor(selectElement, icon_sets = ['ti']) {
constructor(selectElement) {
this.selectElement = selectElement;
this.icon_sets = icon_sets;
}

/**
Expand All @@ -57,11 +54,62 @@ export class WebIconSelector {
*/
init() {
const icons = this.#fetchAvailableIcons();
const pageSize = 1; // 1 page = 1 category
const ArrayAdapter = $.fn.select2.amd.require("select2/data/array");

class CustomDataAdapter extends ArrayAdapter
{
constructor($element, options)
{
super($element, options);
}

query(params, callback)
{
var filtered_results = [];

// filter results based on search term
if (params.term && params.term !== '') {
const uppercase_term = params.term.toUpperCase();

// remove children in category entries that do not match the search term
const icons_copy = JSON.parse(JSON.stringify(icons)); // avoid copying by reference (categories are objects
for (let i = 0; i < icons_copy.length; i++) {
const category = icons_copy[i];
category.children = category.children.filter(child => child.text.toUpperCase().includes(uppercase_term));
if (category.children.length > 0) {
filtered_results.push(category);
}
}

} else {
// no search term, return all categories/icons
filtered_results = icons;
}

// add pagination parameter if missing
if (!("page" in params)) {
params.page = 1;
}

// return only the current page
var data = {};
data.results = filtered_results.slice((params.page - 1) * pageSize, params.page * pageSize);
data.pagination = {};
data.pagination.more = params.page * pageSize < filtered_results.length;

callback(data);
}
}

$(this.selectElement).select2(
{
data: icons,
ajax: {}, // use ajax instead of data option to allow automatic triggering on scrolling
templateResult: this.#renderIcon,
templateSelection: this.#renderIcon
templateSelection: this.#renderIcon,
dataAdapter: CustomDataAdapter,
placeholder: __('Select an icon'),
minimumInputLength: 2,
}
);
}
Expand All @@ -75,35 +123,80 @@ export class WebIconSelector {
*/
#fetchAvailableIcons() {
const icons = [];
const iconset_regex = new RegExp('^.((?:' + this.icon_sets.join('|') + ')-[a-z-]+)::before$');

for (let i = 0; i < document.styleSheets.length; i++) {
const rules = document.styleSheets[i].cssRules;
for(let j = 0; j < rules.length; j++) {
const rule = rules[j];
if (rule.constructor.name !== 'CSSStyleRule') {
continue;
}
// On minified CSS, similar icons will be grouped,
// e.g. `.fa-arrow-turn-right::before,.fa-mail-forward::before,.fa-share::before`.
// Split them to handle them separately.
const selectors = rule.selectorText.split(',');
for(let k = 0; k < selectors.length; k++) {
let matches = selectors[k].trim().match(iconset_regex);
if (matches !== null) {
const cls = matches[1];
const entry = {
id: cls,
text: cls
};
if (!icons.includes(entry)) {
icons.push(entry);
}
}
}

var replacements = {
"Animals": _x('icons', "Animals"),
"Arrows": _x('icons', "Arrows"),
"Badges": _x('icons', "Badges"),
"Brand": _x('icons', "Brand"),
"Buildings": _x('icons', "Buildings"),
"Charts": _x('icons', "Charts"),
"Communication": _x('icons', "Communication"),
"Computers": _x('icons', "Computers"),
"Currencies": _x('icons', "Currencies"),
"Database": _x('icons', "Database"),
"Design": _x('icons', "Design"),
"Development": _x('icons', "Development"),
"Devices": _x('icons', "Devices"),
"Document": _x('icons', "Document"),
"E-commerce": _x('icons', "E-commerce"),
"Electrical": _x('icons', "Electrical"),
"Extensions": _x('icons', "Extensions"),
"Food": _x('icons', "Food"),
"Games": _x('icons', "Games"),
"Gender": _x('icons', "Gender"),
"Gestures": _x('icons', "Gestures"),
"Health": _x('icons', "Health"),
"Laundry": _x('icons', "Laundry"),
"Letters": _x('icons', "Letters"),
"Logic": _x('icons', "Logic"),
"Map": _x('icons', "Map"),
"Maps": _x('icons', "Maps"),
"Math": _x('icons', "Math"),
"Media": _x('icons', "Media"),
"Mood": _x('icons', "Mood"),
"Nature": _x('icons', "Nature"),
"Numbers": _x('icons', "Numbers"),
"Photography": _x('icons', "Photography"),
"Shapes": _x('icons', "Shapes"),
"Sport": _x('icons', "Sport"),
"Symbols": _x('icons', "Symbols"),
"System": _x('icons', "System"),
"Text": _x('icons', "Text"),
"Vehicles": _x('icons', "Vehicles"),
"Version control": _x('icons', "Version control"),
"Weather": _x('icons', "Weather"),
"Zodiac": _x('icons', "Zodiac"),
"Other": _x('icons', "Other")
};

for (const [icon_id, data] of Object.entries(window.tablericons_definitions)) {
let category = data.category;

// if no category is defined, use "Other"
if (category === "") {
category = "Other";
}

// replace category name with translation if available
if (category in replacements) {
category = replacements[category];
}

// category entry will have the format {"text": "Category", "children": []}
// if existing, add the icon to the category children, otherwise create the category before
let category_entry = icons.find(entry => entry.text === category);
if (category_entry === undefined) {
category_entry = {"text": category, "children": []};
icons.push(category_entry);
}

category_entry.children.push({"id": "ti-" + icon_id, "text": icon_id});
}

// sort categories
icons.sort((a, b) => a.text.localeCompare(b.text));

return icons;
}

Expand All @@ -118,7 +211,11 @@ export class WebIconSelector {
if (typeof option.id !== 'undefined') {
let container = document.createElement('span');
const iconset_prefix = option.id.split('-')[0];
container.innerHTML = `<i class="${iconset_prefix} ${option.id}"></i> ${option.id}`;
let style = "";
if (iconset_prefix === "fa") {
style = "style=\"font-family: 'Font Awesome 6 Free', 'Font Awesome 6 Brands';\"";
}
container.innerHTML = `<i class="${iconset_prefix} ${option.id}" ${style}></i> ${option.id}`;
return container;
} else {
return option.text;
Expand Down
35 changes: 35 additions & 0 deletions lib/bundles/tablericons-definitions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* ---------------------------------------------------------------------
*
* GLPI - Gestionnaire Libre de Parc Informatique
*
* http://glpi-project.org
*
* @copyright 2015-2024 Teclib' and contributors.
* @licence https://www.gnu.org/licenses/gpl-3.0.html
*
* ---------------------------------------------------------------------
*
* LICENSE
*
* This file is part of GLPI.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* ---------------------------------------------------------------------
*/

// import has to use the complete path as using `@tabler/icons/tags.json` would be resolved to
// `@tabler/icons/icons/tags.json` due to the package export specs
window.tablericons_definitions = require('../../node_modules/@tabler/icons/tags.json');
3 changes: 1 addition & 2 deletions templates/components/form/fields_macros.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,6 @@
{% macro dropdownWebIcons(name, value, label = '', options = {}) %}
{% set options = {
rand: random(),
icon_sets: ['ti'],
}|merge(options|merge({
noselect2: true,
})) %}
Expand All @@ -607,7 +606,7 @@
<script type="module">
import('{{ js_path('js/modules/Form/WebIconSelector.js') }}').then((m) => {
const dropdown_id = '{{ ('dropdown_' ~ name ~ options.rand)|replace({'[': '_', ']': '_'}) }}';
const selector = new m.default(document.getElementById(dropdown_id), {{ options.icon_sets|json_encode|raw }});
const selector = new m.default(document.getElementById(dropdown_id));
selector.init();
});
</script>
Expand Down
4 changes: 1 addition & 3 deletions templates/pages/setup/manuallink.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@
{% block more_fields %}
{{ fields.textField('url', item.fields['url'], __('URL')) }}
{{ fields.dropdownYesNo('open_window', item.fields['open_window'], __('Open in a new window')) }}
{{ fields.dropdownWebIcons('icon', item.fields['icon'], __('Icon'), {
icon_sets: ['fa']
}) }}
{{ fields.dropdownWebIcons('icon', item.fields['icon'], __('Icon')) }}

{% if item.isNewItem() %}
{{ inputs.hidden('itemtype', parent_item.itemtype) }}
Expand Down
62 changes: 16 additions & 46 deletions tests/js/modules/Form/WebIconSelector.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,12 @@ describe('Web Icon Selector', () => {
const selectElement = document.createElement('select');
const webIconSelector = new WebIconSelector(selectElement);
expect(webIconSelector.selectElement).toBe(selectElement);
expect(webIconSelector.icon_sets).toEqual(['ti']);

const webIconSelector2 = new WebIconSelector(selectElement, ['ti', 'fa']);
const webIconSelector2 = new WebIconSelector(selectElement);
expect(webIconSelector2.selectElement).toBe(selectElement);
expect(webIconSelector2.icon_sets).toEqual(['ti', 'fa']);

const webIconSelector3 = new WebIconSelector(selectElement, ['fa']);
const webIconSelector3 = new WebIconSelector(selectElement);
expect(webIconSelector3.selectElement).toBe(selectElement);
expect(webIconSelector3.icon_sets).toEqual(['fa']);
});
test('Init select2', () => {
$('body').append('<select id="test"></select>');
Expand All @@ -80,47 +77,20 @@ describe('Web Icon Selector', () => {
webIconSelector.init();
await new Promise(process.nextTick);

const select2_options = $('#test').data('select2').results.data._dataToConvert;
expect(select2_options).toBeDefined();
expect(select2_options.length).toBe(1);
// each option id and text should match and contain 'ti-'
select2_options.forEach((option) => {
expect(option.id).toBe(option.text);
expect(option.id).toContain('ti-');
$('#test').data('select2').results.data.query({
term: '',
}, (results) => {
// One page/category returned by default
expect(results.results.length).toBe(1);
expect(results.results[0].text).toBe('Animals');
expect(results.results[0].children.length).toBeGreaterThan(1);
// More pages available
expect(results.pagination.more).toBeTrue();
// each option id and text should match and contain 'ti-'
results.results[0].children.forEach((option) => {
expect(option.id).toBe('ti-' + option.text);
expect(option.id).toContain('ti-');
});
});

// Test with FontAwesome
const webIconSelector2 = new WebIconSelector(document.getElementById('test2'), ['fa']);
webIconSelector2.init();

const select2_options2 = $('#test2').data('select2').results.data._dataToConvert;
expect(select2_options2).toBeDefined();
expect(select2_options2.length).toBe(4);
// each option id and text should match and contain 'fa-'
select2_options2.forEach((option) => {
expect(option.id).toBe(option.text);
expect(option.id).toContain('fa-');
});

// Test with both icon sets
const webIconSelector3 = new WebIconSelector(document.getElementById('test3'), ['ti', 'fa']);
webIconSelector3.init();

let has_ti = false;
let has_fa = false;
const select2_options3 = $('#test3').data('select2').results.data._dataToConvert;
expect(select2_options3).toBeDefined();
expect(select2_options3.length).toBe(5);
// each option id and text should match and contain 'fa-' or 'ti-' and both types of icons should be present
select2_options3.forEach((option) => {
expect(option.id).toBe(option.text);
if (option.id.includes('fa-')) {
has_fa = true;
} else if (option.id.includes('ti-')) {
has_ti = true;
}
});
expect(has_ti).toBeTrue();
expect(has_fa).toBeTrue();
});
});

0 comments on commit 52eb093

Please sign in to comment.