From b9d094683a6ed02e1bf4b1f703ad3a93b8affae4 Mon Sep 17 00:00:00 2001 From: AdrienClairembault Date: Wed, 11 Dec 2024 17:27:40 +0100 Subject: [PATCH 1/3] Add illustration picker --- .stylelintrc.js | 1 - css/includes/_includes.scss | 2 + .../components/_illustration-picker.scss | 45 ++++ css/includes/components/_richtext.scss | 5 +- css/includes/components/_utils.scss | 56 ++++ css/includes/pages/_search.scss | 2 +- css/standalone/dashboard.scss | 2 +- css/standalone/marketplace.scss | 2 +- js/common.js | 18 ++ js/modules/IllustrationPicker/Controller.js | 241 ++++++++++++++++++ .../Glpi/UI/IllustrationManagerTest.php | 121 +++++++++ .../View/Extension/IllustrationExtension.php | 10 +- .../UI/Illustration/SearchController.php | 65 +++++ src/Glpi/Form/Form.php | 2 +- src/Glpi/Helpdesk/Tile/FormTile.php | 2 +- src/Glpi/UI/IllustrationManager.php | 64 ++++- .../components/form/fields_macros.html.twig | 52 ++++ .../components/illustration/icon.svg.twig | 3 +- .../illustration/icon_picker_modal.html.twig | 80 ++++++ .../icon_picker_search_results.html.twig | 95 +++++++ .../admin/form/service_catalog_tab.html.twig | 46 ++-- .../service_catalog/service_catalog_tab.cy.js | 6 +- tests/cypress/e2e/illustration_picker.cy.js | 120 +++++++++ 23 files changed, 1000 insertions(+), 40 deletions(-) create mode 100644 css/includes/components/_illustration-picker.scss create mode 100644 css/includes/components/_utils.scss create mode 100644 js/modules/IllustrationPicker/Controller.js create mode 100644 phpunit/functional/Glpi/UI/IllustrationManagerTest.php create mode 100644 src/Glpi/Controller/UI/Illustration/SearchController.php create mode 100644 templates/components/illustration/icon_picker_modal.html.twig create mode 100644 templates/components/illustration/icon_picker_search_results.html.twig create mode 100644 tests/cypress/e2e/illustration_picker.cy.js diff --git a/.stylelintrc.js b/.stylelintrc.js index b378a2bd76a..34e83551f23 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -17,7 +17,6 @@ module.exports = { "color-function-notation": null, // DISABLE: Expected modern color-function notation "declaration-block-no-redundant-longhand-properties": null, // DISABLE Expected shorthand property "flex-flow" "media-feature-range-notation": "prefix", - "selector-not-notation": "simple", //DISABLE Expected complex :not() pseudo-class notation "scss/at-rule-conditional-no-parentheses": null, "scss/no-global-function-names": null, // scssphp do not support usage of SASS modules diff --git a/css/includes/_includes.scss b/css/includes/_includes.scss index 4c87c684b37..bfdfdb21ca5 100644 --- a/css/includes/_includes.scss +++ b/css/includes/_includes.scss @@ -51,6 +51,7 @@ $is-dark: false !default; @import "components/form/form-destination"; @import "components/fuzzy"; @import "components/global-menu"; +@import "components/illustration-picker"; @import "components/log_viewer"; @import "components/browser_tree"; @import "components/mini-tabs"; @@ -66,6 +67,7 @@ $is-dark: false !default; @import "components/sortable"; @import "components/tabs"; @import "components/tinymce"; +@import "components/utils"; @import "components/creator_badge"; @import "components/itilobject/footer"; @import "components/itilobject/layout"; diff --git a/css/includes/components/_illustration-picker.scss b/css/includes/components/_illustration-picker.scss new file mode 100644 index 00000000000..f3d039c4b89 --- /dev/null +++ b/css/includes/components/_illustration-picker.scss @@ -0,0 +1,45 @@ +/*! + * --------------------------------------------------------------------- + * + * 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 . + * + * --------------------------------------------------------------------- + */ + +.illustration-selector { + padding-bottom: 7px; + border-color: var(--tblr-card-border-color); + box-shadow: var(--tblr-box-shadow-input) !important; + border-radius: 10px !important; // Must match border radius used for tinymce + + &:hover { + border-color: var(--tblr-secondary); + cursor: pointer; + } + + transition: border-color 0.3s; +} diff --git a/css/includes/components/_richtext.scss b/css/includes/components/_richtext.scss index 5a9be6c1a6b..b9a585f5d33 100644 --- a/css/includes/components/_richtext.scss +++ b/css/includes/components/_richtext.scss @@ -54,9 +54,10 @@ body.mce-content-body { justify-content: flex-end; } -.tox.tox-tinymce { - border: var(--tblr-border-width) solid var(--tblr-border-color); +.tox.tox-tinymce:not(.content-editable-tinymce .tox.tox-tinymce) { + border: var(--tblr-border-width) solid var(--tblr-border-color) !important; border-radius: var(--tblr-border-radius); + box-shadow: var(--tblr-box-shadow-input) !important; } .rich_text_container { diff --git a/css/includes/components/_utils.scss b/css/includes/components/_utils.scss new file mode 100644 index 00000000000..04355a82d65 --- /dev/null +++ b/css/includes/components/_utils.scss @@ -0,0 +1,56 @@ +/*! + * --------------------------------------------------------------------- + * + * 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 . + * + * --------------------------------------------------------------------- + */ + +.aspect-ratio-1 { + aspect-ratio: 1; +} + +.border-secondary-hover { + border-color: transparent; + + &:hover { + border-color: var(--tblr-secondary); + } + + transition: border-color 0.3s; +} + +// Apply the same font-size and line-height as btn-text. +// Useful when replacing a btn-text content by a loading icon. +.btn-text-loading { + font-size: var(--tblr-btn-font-size) !important; + line-height: var(--tblr-btn-line-height) !important; +} + +.w-fit-content { + width: fit-content !important; +} diff --git a/css/includes/pages/_search.scss b/css/includes/pages/_search.scss index ec1e0549cdb..7af5c1ae702 100644 --- a/css/includes/pages/_search.scss +++ b/css/includes/pages/_search.scss @@ -280,7 +280,7 @@ table.search-results { vertical-align: bottom !important;// Fix alignment for when no sorting indicator element is present } - thead th[data-searchopt-id]:not([data-searchopt-id=""]):not([data-nosort]) { + thead th[data-searchopt-id]:not([data-searchopt-id=""], [data-nosort]) { cursor: pointer; .sort-indicator { diff --git a/css/standalone/dashboard.scss b/css/standalone/dashboard.scss index 7ec307b880e..4d903d00d44 100644 --- a/css/standalone/dashboard.scss +++ b/css/standalone/dashboard.scss @@ -331,7 +331,7 @@ $break_tablet: 1400px; .edit-dashboard-properties { display: none; - input.dashboard-name:not(.submit):not([type="submit"]):not([type="reset"]):not([type="checkbox"]):not([type="radio"]):not(.select2-search__field) { + input.dashboard-name:not(.submit, [type="submit"], [type="reset"], [type="checkbox"], [type="radio"], .select2-search__field) { min-width: 200px; resize: horizontal; } diff --git a/css/standalone/marketplace.scss b/css/standalone/marketplace.scss index eb610912904..ccd6de12660 100644 --- a/css/standalone/marketplace.scss +++ b/css/standalone/marketplace.scss @@ -446,7 +446,7 @@ $break_s_screen: 1400px; padding: 8px 10px; } - &:not(.current):not(.nb_plugin):not(.nav-disabled):hover { + &:not(.current, .nb_plugin, .nav-disabled):hover { background-color: #ddd; cursor: pointer; } diff --git a/js/common.js b/js/common.js index 77fea4cedad..c5908cfeb2b 100644 --- a/js/common.js +++ b/js/common.js @@ -1975,3 +1975,21 @@ window.displaySessionMessages = () => { }); }); }; + +// Add/remove a special data attribute to bootstrap's modals when they are +// displayed/hidden. +// This is needed for e2e testing as bootstrap have some compatibility issues +// with cypress. +// See https://github.com/cypress-io/cypress/issues/25202. +document.addEventListener('shown.bs.modal', (e) => { + const modal = e.target.closest('.modal'); + if (modal) { + modal.setAttribute('data-cy-shown', 'true'); + } +}); +document.addEventListener('hidden.bs.modal', (e) => { + const modal = e.target.closest('.modal'); + if (modal) { + modal.setAttribute('data-cy-shown', 'false'); + } +}); diff --git a/js/modules/IllustrationPicker/Controller.js b/js/modules/IllustrationPicker/Controller.js new file mode 100644 index 00000000000..234e51d8f33 --- /dev/null +++ b/js/modules/IllustrationPicker/Controller.js @@ -0,0 +1,241 @@ +/** + * --------------------------------------------------------------------- + * + * 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 . + * + * --------------------------------------------------------------------- + */ + +/* global bootstrap, _ */ + +export class GlpiIllustrationPickerController +{ + /** + * @type {HTMLElement} + */ + #container; + + /** + * @type {Number} + */ + #running_search_requests_count = 0; + + constructor(container) + { + this.#container = container; + this.#initEventListeners(); + } + + #initEventListeners() + { + // Here we must watch for events on the whole container as its content will + // be dynamically updated when using search or pagination. + + // Watch for icon selection + this.#container.addEventListener("click", (e) => { + const illustration = e.target.closest('[data-glpi-icon-picker-value]'); + + // Click must be on an illustration. + if (illustration === null) { + return; + } + + this.#setIllustration(illustration); + }); + + // Watch for page change + this.#container.addEventListener("click", (e) => { + const go_to_button = e.target.closest('[data-glpi-icon-picker-go-to-page]'); + + // Click must be on a "go to page..." button. + if (go_to_button === null) { + return; + } + + const page = go_to_button.dataset['glpiIconPickerGoToPage']; + this.#goToPage(page); + }); + + // Watch for filter change + const debouncedSearch = _.debounce( + (filter) => this.#filterIcons(filter), + 200, + ); + this.#container.addEventListener("input", (e) => { + const filter_input = e.target.closest('[data-glpi-icon-picker-filter]'); + + // Input event must come from the filter input. + if (filter_input === null) { + return; + } + + debouncedSearch(filter_input.value); + }); + } + + #setIllustration(illustration) + { + // Gets details of the newly selected item. + const illustration_id = illustration.dataset['glpiIconPickerValue']; + const illustration_title = illustration + .querySelector('svg') + .querySelector('title') + ; + + // Apply the new illustration id to the hidden input. + this.#getSelectedIllustrationsInput().value = illustration_id; + + // Update the preview of the selected item. + const selected_svg = this.#container + .querySelector('[data-glpi-icon-picker-value-preview]') + .querySelector('svg') + ; + const title = selected_svg.querySelector('title'); + const use = selected_svg.querySelector('use'); + const xlink = use.getAttribute('xlink:href'); + + use.setAttribute( + 'xlink:href', + xlink.replace(/#.*/, `#${illustration_id}`) + ); + title.innerHTML = illustration_title.innerHTML; + } + + /** + * Note: a new search might be triggered while the previous one is still + * running if the server is very slow. + * Thus we must keep track of running requests using to make sure the + * loading styles are only removed when the last request is finished. + */ + async #filterIcons(filter = "") + { + // Apply loading styles. + if (this.#running_search_requests_count == 0) { + this.#getSearchDefaultIcon().classList.add('d-none'); + this.#getSearchLoadingIcon().classList.remove('d-none'); + this.#getSearchResultsDiv().style.opacity = 0.7; + this.#getSearchResultsDiv().style['pointer-events'] = 'none'; + } + this.#running_search_requests_count++; + + // Execute search (always go back to first page as results will change) + await this.#fetchIcons(filter, 1); + + // Remove loading styles + this.#running_search_requests_count--; + if (this.#running_search_requests_count == 0) { + this.#getSearchDefaultIcon().classList.remove('d-none'); + this.#getSearchLoadingIcon().classList.add('d-none'); + this.#getSearchResultsDiv().style.opacity = 1; + this.#getSearchResultsDiv().style['pointer-events'] = null; + } + } + + async #goToPage(page) + { + // Remove active button + this.#container + .querySelectorAll('[data-glpi-icon-picker-go-to-page]') + .forEach((button) => button.classList.remove('active')) + ; + + // Apply loading indicator to the new active button + const button = this.#container + .querySelector(`[data-glpi-icon-picker-go-to-page="${page}"]`) + ; + const button_text = button.querySelector( + '[data-glpi-icon-picker-pagination-text' + ); + const button_loading_indicator = button.querySelector( + '[data-glpi-icon-picker-pagination-loading-icon' + ); + + button.classList.add('active'); + button_text.classList.add('d-none'); + button_loading_indicator.classList.remove('d-none'); + + // Apply loading indicator to the search results + this.#getSearchResultsDiv().style.opacity = 0.7; + this.#getSearchResultsDiv().style['pointer-events'] = 'none'; + + await this.#fetchIcons(this.#getFilterInput().value, page); + + // Remove loading indicator from the search results. + this.#getSearchResultsDiv().style.opacity = 1; + this.#getSearchResultsDiv().style['pointer-events'] = null; + + // Note: the pagination will be refreshed with new content from the + // server, thus we don't need to revert the style/classes changes to the + // pagination buttons. + } + + async #fetchIcons(filter = "", page = 1) + { + const url = `${CFG_GLPI.root_doc}/UI/Illustration/Search`; + const url_params = new URLSearchParams({ + filter : filter, + page : page, + page_size: this.#getPageSizeValue(), + }); + const response = await fetch(`${url}?${url_params}`); + + this.#getSearchResultsDiv().innerHTML = await response.text(); + } + + #getFilterInput() + { + return this.#container.querySelector('[data-glpi-icon-picker-filter]'); + } + + #getPageSizeValue() + { + return this.#container.querySelector('[data-glpi-icon-picker-page-size]') + .dataset['glpiIconPickerPageSize'] + ; + } + + #getSelectedIllustrationsInput() + { + return this.#container.querySelector('[data-glpi-icon-picker-value]'); + } + + #getSearchDefaultIcon() + { + return this.#container.querySelector('[data-glpi-icon-picker-filter-default-icon') + ; + } + + #getSearchLoadingIcon() + { + return this.#container.querySelector('[data-glpi-icon-picker-filter-loading-icon'); + } + + #getSearchResultsDiv() + { + return this.#container.querySelector('[data-glpi-icon-picker-body]'); + } +} diff --git a/phpunit/functional/Glpi/UI/IllustrationManagerTest.php b/phpunit/functional/Glpi/UI/IllustrationManagerTest.php new file mode 100644 index 00000000000..4de85c1f9b5 --- /dev/null +++ b/phpunit/functional/Glpi/UI/IllustrationManagerTest.php @@ -0,0 +1,121 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace tests\units\Glpi\UI; + +use Glpi\UI\IllustrationManager; +use GLPITestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +final class IllustrationManagerTest extends GLPITestCase +{ + public static function searchIconsUsingFilterProvider(): iterable + { + yield [ + 'filter' => 'Service', + 'expected' => ['request-service'] + ]; + yield [ + 'filter' => 'backup And restoration', + 'expected' => ['backup-restoration-1', 'backup-restoration-2'] + ]; + } + + #[DataProvider('searchIconsUsingFilterProvider')] + public function testSearchIconsIdsUsingFilter( + string $filter, + array $expected, + ): void { + // Act: get icons matching the requester filter. + $manager = new IllustrationManager(); + $ids = $manager->searchIcons(filter: $filter); + + // Assert: the expected icons ids are found + $this->assertEquals($expected, $ids); + } + + public static function searchIconsIdsUsingPaginationProvider(): iterable + { + yield [ + 'page' => 1, + 'page_size' => 3, + 'expected' => [ + 'approve-requests', + 'asset-cartridge', + 'asset-desktop-1', + ], + ]; + + yield [ + 'page' => 2, + 'page_size' => 3, + 'expected' => [ + 'asset-desktop-2', + 'asset-laptop', + 'asset-lost', + ], + ]; + + yield [ + 'page' => 1, + 'page_size' => 10, + 'expected' => [ + 'approve-requests', + 'asset-cartridge', + 'asset-desktop-1', + 'asset-desktop-2', + 'asset-laptop', + 'asset-lost', + 'asset-network-equipment', + 'asset-peripheral', + 'asset-phone', + 'asset-printer', + ], + ]; + } + + #[DataProvider('searchIconsIdsUsingPaginationProvider')] + public function testSearchIconsIdsUsingPagination( + int $page, + int $page_size, + array $expected, + ): void { + // Act: get icons matching the requester filter. + $manager = new IllustrationManager(); + $ids = $manager->searchIcons(page: $page, page_size: $page_size); + + // Assert: the expected icons ids are found + $this->assertEquals($expected, $ids); + } +} diff --git a/src/Glpi/Application/View/Extension/IllustrationExtension.php b/src/Glpi/Application/View/Extension/IllustrationExtension.php index c039243fd92..5694b815610 100644 --- a/src/Glpi/Application/View/Extension/IllustrationExtension.php +++ b/src/Glpi/Application/View/Extension/IllustrationExtension.php @@ -56,10 +56,18 @@ public function getFunctions(): array new TwigFunction('render_illustration', [$this, 'renderIllustration'], [ 'is_safe' => ['html'], ]), + new TwigFunction( + 'searchIcons', + [$this->illustration_manager, 'searchIcons'], + ), + new TwigFunction( + 'countIcons', + [$this->illustration_manager, 'countIcons'], + ), ]; } - public function renderIllustration(string $filename, int $size = 100): string + public function renderIllustration(string $filename, ?int $size = null): string { return $this->illustration_manager->renderIcon($filename, $size); } diff --git a/src/Glpi/Controller/UI/Illustration/SearchController.php b/src/Glpi/Controller/UI/Illustration/SearchController.php new file mode 100644 index 00000000000..21ff90af180 --- /dev/null +++ b/src/Glpi/Controller/UI/Illustration/SearchController.php @@ -0,0 +1,65 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Controller\UI\Illustration; + +use Glpi\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +final class SearchController extends AbstractController +{ + #[Route( + "/UI/Illustration/Search", + name: "glpi_ui_illustration_search", + )] + public function __invoke(Request $request): Response + { + // Read parameters + $filter = $request->query->getString('filter', ""); + $page = $request->query->getInt('page', 1); + $page_size = $request->query->getInt('page_size', 30); + + // Output modal body + return $this->render( + 'components/illustration/icon_picker_search_results.html.twig', + [ + 'filter' => $filter, + 'page' => $page, + 'page_size' => $page_size, + ] + ); + } +} diff --git a/src/Glpi/Form/Form.php b/src/Glpi/Form/Form.php index 496a68b9986..33584a473cc 100644 --- a/src/Glpi/Form/Form.php +++ b/src/Glpi/Form/Form.php @@ -879,7 +879,7 @@ public function getServiceCatalogItemDescription(): string #[Override] public function getServiceCatalogItemIllustration(): string { - return $this->fields['illustration'] ?? ""; + return $this->fields['illustration'] ?: 'request-service'; } #[Override] diff --git a/src/Glpi/Helpdesk/Tile/FormTile.php b/src/Glpi/Helpdesk/Tile/FormTile.php index c02f9f8fdc6..19a720539c8 100644 --- a/src/Glpi/Helpdesk/Tile/FormTile.php +++ b/src/Glpi/Helpdesk/Tile/FormTile.php @@ -89,7 +89,7 @@ public function getIllustration(): string if ($this->form === null) { return ""; } - return $this->form->fields['illustration']; + return $this->form->getServiceCatalogItemIllustration(); } #[Override] diff --git a/src/Glpi/UI/IllustrationManager.php b/src/Glpi/UI/IllustrationManager.php index b88202cf35c..4a5a1f690fa 100644 --- a/src/Glpi/UI/IllustrationManager.php +++ b/src/Glpi/UI/IllustrationManager.php @@ -38,8 +38,9 @@ final class IllustrationManager { - public string $icons_definition_file; - public string $icons_sprites_path; + private string $icons_definition_file; + private string $icons_sprites_path; + private ?array $icons_definitions = null; public function __construct( ?string $icons_definition_file = null, @@ -60,24 +61,73 @@ public function __construct( */ public function renderIcon(string $icon_id, ?int $size = null): string { + $icons = $this->getIconsDefinitions(); $twig = TemplateRenderer::getInstance(); return $twig->render('components/illustration/icon.svg.twig', [ 'file_path' => $this->icons_sprites_path, 'icon_id' => $icon_id, 'size' => $this->computeSize($size), + 'title' => $icons[$icon_id]['title'] ?? "", ]); } /** @return string[] */ public function getAllIconsIds(): array { - $json = file_get_contents($this->icons_definition_file); - if ($json === false) { - throw new \RuntimeException(); + return array_keys($this->getIconsDefinitions()); + } + + public function countIcons(string $filter = ""): int + { + if ($filter == "") { + return count($this->getIconsDefinitions()); + } + + $icons = array_filter( + $this->getIconsDefinitions(), + fn ($icon) => str_contains( + strtolower($icon['title']), + strtolower($filter), + ) + ); + + return count($icons); + } + + /** @return string[] */ + public function searchIcons( + string $filter = "", + int $page = 1, + int $page_size = 30, + ): array { + $icons = array_filter( + $this->getIconsDefinitions(), + fn ($icon) => str_contains( + strtolower($icon['title']), + strtolower($filter), + ) + ); + + $icons = array_slice( + array: $icons, + offset: ($page - 1) * $page_size, + length: $page_size, + ); + + return array_keys($icons); + } + + private function getIconsDefinitions(): array + { + if ($this->icons_definitions === null) { + $json = file_get_contents($this->icons_definition_file); + if ($json === false) { + throw new \RuntimeException(); + } + $this->icons_definitions = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); } - $definition = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); - return array_keys($definition); + return $this->icons_definitions; } private function computeSize(?int $size = null): string diff --git a/templates/components/form/fields_macros.html.twig b/templates/components/form/fields_macros.html.twig index ac2a931c62d..1a0ebe1bca4 100644 --- a/templates/components/form/fields_macros.html.twig +++ b/templates/components/form/fields_macros.html.twig @@ -988,3 +988,55 @@ }); {% endmacro %} + +{% macro illustrationField(name, value, label = '', options = {}) %} + {% set field %} + {% set container_id = "container-" ~ random() %} + +
+ + + {# Display the illustration, trigger the modal on click #} + {% set modal_id = "illustration-modal-" ~ random() %} +
+
+ {{ render_illustration(value, 100) }} +
+
+ + {# Render the modal content #} + {{ include('components/illustration/icon_picker_modal.html.twig', { + 'id': modal_id, + }, with_context: false) }} + + {# Start js controller #} + +
+ {% endset %} + + {% set options = { + 'id': '%id%', + }|merge(options) %} + {{ _self.field(name, field, label, options) }} +{% endmacro %} diff --git a/templates/components/illustration/icon.svg.twig b/templates/components/illustration/icon.svg.twig index d484c52fbc0..eb58a03f3e1 100644 --- a/templates/components/illustration/icon.svg.twig +++ b/templates/components/illustration/icon.svg.twig @@ -30,6 +30,7 @@ # --------------------------------------------------------------------- #} - + + {{ title }} diff --git a/templates/components/illustration/icon_picker_modal.html.twig b/templates/components/illustration/icon_picker_modal.html.twig new file mode 100644 index 00000000000..fedcfdfce94 --- /dev/null +++ b/templates/components/illustration/icon_picker_modal.html.twig @@ -0,0 +1,80 @@ +{# + # --------------------------------------------------------------------- + # + # 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 . + # + # --------------------------------------------------------------------- + #} + + diff --git a/templates/components/illustration/icon_picker_search_results.html.twig b/templates/components/illustration/icon_picker_search_results.html.twig new file mode 100644 index 00000000000..28d8f1e71e5 --- /dev/null +++ b/templates/components/illustration/icon_picker_search_results.html.twig @@ -0,0 +1,95 @@ +{# + # --------------------------------------------------------------------- + # + # 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 . + # + # --------------------------------------------------------------------- + #} + +{% set total_pages = (countIcons(filter) / page_size)|round(0, 'ceil') %} +{% set icon_ids = searchIcons(filter, page, page_size) %} + +
+ {% if icon_ids|length %} +
+ {% for icon_id in icon_ids %} +
+
+
+ {{ render_illustration(icon_id) }} +
+
+
+ {% endfor %} +
+ + {% if total_pages > 1 %} +
+ + {{ __('Page %s of %s')|format(page, total_pages) }} + + + {% for i in range(1, total_pages) %} + {# Display go-to buttons for: + - The first page + - The current page +/- 1 + - The last page + #} + {% if i in [1, page - 1, page, page + 1, total_pages] %} + + {% endif %} + {% endfor %} +
+ {% endif %} + {% else %} +
+

{{ __("No results found") }}

+

+ {{ __("Try others keywords or filters to find what you're looking for.") }} +

+
+ {% endif %} +
diff --git a/templates/pages/admin/form/service_catalog_tab.html.twig b/templates/pages/admin/form/service_catalog_tab.html.twig index 459db6a4920..97381614148 100644 --- a/templates/pages/admin/form/service_catalog_tab.html.twig +++ b/templates/pages/admin/form/service_catalog_tab.html.twig @@ -44,27 +44,33 @@ action="{{ form.getFormURL() }}" data-submit-once > - {{ fields.textField( - 'illustration', - form.fields.illustration, - __('Illustration'), - { - 'is_horizontal': false, - 'full_width' : true, - } - ) }} +
+
+ {{ fields.textareaField( + 'description', + form.fields.description, + __('Description'), + { + 'is_horizontal': false, + 'full_width' : true, + 'enable_richtext': true, + 'enable_images': false, + } + ) }} +
- {{ fields.textareaField( - 'description', - form.fields.description, - __('Description'), - { - 'is_horizontal': false, - 'full_width' : true, - 'enable_richtext': true, - 'enable_images': false, - } - ) }} +
+ {{ fields.illustrationField( + 'illustration', + form.getServiceCatalogItemIllustration(), + __('Illustration'), + { + 'is_horizontal': false, + 'full_width' : true, + } + ) }} +
+
{{ fields.dropdownField( 'Glpi\\Form\\Category', diff --git a/tests/cypress/e2e/form/service_catalog/service_catalog_tab.cy.js b/tests/cypress/e2e/form/service_catalog/service_catalog_tab.cy.js index 0ceb34b5b64..14e176172d6 100644 --- a/tests/cypress/e2e/form/service_catalog/service_catalog_tab.cy.js +++ b/tests/cypress/e2e/form/service_catalog/service_catalog_tab.cy.js @@ -53,12 +53,10 @@ describe('Service catalog tab', () => { // Make sure the values we are about to apply are are not already set to // prevent false negative. - cy.findByRole("textbox", {'name': 'Illustration'}).should('not.contain.text', 'request-service'); cy.findByLabelText("Description").awaitTinyMCE().should('not.contain.text', 'My description'); cy.getDropdownByLabelText("Category").should('not.have.text', category_name); // Set values - cy.findByRole("textbox", {'name': 'Illustration'}).type('request-service'); cy.findByLabelText("Description").awaitTinyMCE().type('My description'); cy.getDropdownByLabelText('Category').selectDropdownValue(category_dropdown_value); @@ -67,8 +65,10 @@ describe('Service catalog tab', () => { cy.findByRole('alert').should('contain.text', 'Item successfully updated'); // Validate values - cy.findByRole("textbox", {'name': 'Illustration'}).should('have.value', 'request-service'); cy.findByLabelText("Description").awaitTinyMCE().should('contain.text', 'My description'); cy.getDropdownByLabelText("Category").should('have.text', category_name); + + // Note: picking an illustration is not validated here as it is already + // done in the illustration_picker.cy.js test. }); }); diff --git a/tests/cypress/e2e/illustration_picker.cy.js b/tests/cypress/e2e/illustration_picker.cy.js new file mode 100644 index 00000000000..e2644c522c4 --- /dev/null +++ b/tests/cypress/e2e/illustration_picker.cy.js @@ -0,0 +1,120 @@ +/** + * --------------------------------------------------------------------- + * + * 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 . + * + * --------------------------------------------------------------------- + */ + +describe('Illustration picker', () => { + beforeEach(() => { + // Go to service catalog config on a freshly created form + cy.login(); + cy.createFormWithAPI().visitFormTab('ServiceCatalog'); + }); + + function openIllustrationPicker() + { + // Open illustration picker + cy.findByRole('dialog').should('not.exist'); + cy.findByRole('button', {'name': "Select an illustration"}).click(); + cy.findByRole('dialog').should('be.visible'); + cy.findByRole('dialog').should('have.attr', 'data-cy-shown', 'true'); + } + + it('Can pick an image', () => { + // The default icon should be selected. + cy.findByRole('img', {'name': 'Request a service'}).should('be.visible'); + + // Open icon picker + openIllustrationPicker(); + + // Select another icon, the modal should close itself and the newly selected + // icon must be displayed. + cy.findByRole('img', {'name': 'Cartridge'}).click(); + cy.findByRole('dialog').should('not.exist'); + cy.findByRole('img', {'name': 'Cartridge'}).should('be.visible'); + cy.findByRole('img', {'name': 'Request a service'}).should('not.exist'); + + // Save and make sure the newly selected image is here. + cy.findByRole('button', {'name': 'Save changes'}).click(); + cy.findByRole('img', {'name': 'Cartridge'}).should('be.visible'); + cy.findByRole('img', {'name': 'Request a service'}).should('not.exist'); + }); + + it('Can use pagination', () => { + const icons_from_first_page = [ + 'Cartridge', + 'Desktop 1', + 'Network equipment', + ]; + const icons_from_second_page = [ + 'Shared folder', + 'Training', + 'VPN', + ]; + + // We are on the first page by default. + openIllustrationPicker(); + icons_from_first_page.forEach((name) => { + cy.findByRole('img', {'name': name}).should('be.visible'); + }); + icons_from_second_page.forEach((name) => { + cy.findByRole('img', {'name': name}).should('not.exist'); + }); + + // Go to second page. + cy.findByRole('button', {'name': 'Go to page 2'}).click(); + icons_from_first_page.forEach((name) => { + cy.findByRole('img', {'name': name}).should('not.exist'); + }); + icons_from_second_page.forEach((name) => { + cy.findByRole('img', {'name': name}).should('be.visible'); + }); + cy.findByRole('dialog').should('be.visible'); + }); + + it('Can search for icons', () => { + openIllustrationPicker(); + cy.findByRole('textbox', {'name': "Search"}).type("Business Intelligence and Reporting"); + + const expected_icons = [ + 'Business Intelligence and Reporting 1', + 'Business Intelligence and Reporting 2', + 'Business Intelligence and Reporting 3', + ]; + + // Only 3 icons must be found + cy.findByRole('dialog') + .findAllByRole('img') + .should('have.length', expected_icons.length) + ; + expected_icons.forEach((name) => { + cy.findByRole('img', {'name': name}).should('be.visible'); + }); + }); +}); From a1e83dbb1ed0e1382f342317c82130b62c2ece7a Mon Sep 17 00:00:00 2001 From: Adrien Clairembault <42734840+AdrienClairembault@users.noreply.github.com> Date: Wed, 18 Dec 2024 09:41:43 +0100 Subject: [PATCH 2/3] Apply label suggestion Co-authored-by: Curtis Conard --- .../illustration/icon_picker_search_results.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/components/illustration/icon_picker_search_results.html.twig b/templates/components/illustration/icon_picker_search_results.html.twig index 28d8f1e71e5..578b6230888 100644 --- a/templates/components/illustration/icon_picker_search_results.html.twig +++ b/templates/components/illustration/icon_picker_search_results.html.twig @@ -88,7 +88,7 @@

{{ __("No results found") }}

- {{ __("Try others keywords or filters to find what you're looking for.") }} + {{ __("Try different keywords or filters.") }}

{% endif %} From 3b4ee5bd54bedf4a915e80a356c2345e2e082792 Mon Sep 17 00:00:00 2001 From: AdrienClairembault Date: Thu, 19 Dec 2024 10:56:08 +0100 Subject: [PATCH 3/3] Adapt responsive breakpoints --- .../illustration/icon_picker_search_results.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/components/illustration/icon_picker_search_results.html.twig b/templates/components/illustration/icon_picker_search_results.html.twig index 578b6230888..7199df1996e 100644 --- a/templates/components/illustration/icon_picker_search_results.html.twig +++ b/templates/components/illustration/icon_picker_search_results.html.twig @@ -42,7 +42,7 @@ {% if icon_ids|length %}
{% for icon_id in icon_ids %} -
+
{{ render_illustration(icon_id) }}