From 66345204ffd2125f869592313911fbde882ba318 Mon Sep 17 00:00:00 2001 From: Stephan Kergomard Date: Tue, 7 Jul 2026 13:06:34 +0200 Subject: [PATCH] [FEATURE] UI: add `Column\Listing` component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new `UI\Component\Table\Column\Listing` component, which accepts an `UI\Component\Listing\Unordered` or `…\Ordered` component. To keep rows consistent a expand/collapse mechanism was put in place, truncating list items at some point if used inside a table column. This makes the `UI\Component\Table\Column\LinkListing` component obsolete, which has been removed during this process. Usages in ILIAS were updated to use the new listing component instead. Co-authored-by: Thibeau Fuhrer --- .../ConsultationHours/BookingTableGUI.php | 6 +- .../Grouping/Table/GroupingHandler.php | 2 +- .../Service/Table/TableAdapterGUI.php | 2 +- .../classes/class.AssignMaterialsTable.php | 2 +- components/ILIAS/UI/UI.php | 12 +++ .../resources/js/Listing/dist/listing.min.js | 15 +++ .../UI/resources/js/Listing/rollup.config.js | 43 +++++++++ .../js/Listing/src/createExpandableList.js | 74 +++++++++++++++ .../UI/resources/js/Listing/src/listing.js | 27 ++++++ .../ILIAS/UI/src/Component/Listing/Inline.php | 4 +- .../UI/src/Component/Listing/Ordered.php | 4 +- .../UI/src/Component/Listing/Unordered.php | 4 +- .../UI/src/Component/Table/Column/Factory.php | 19 +++- .../Column/{LinkListing.php => Listing.php} | 2 +- .../Component/Listing/Inline.php | 2 + .../Listing/ListingRendererFactory.php | 54 +++++++++++ .../Component/Listing/Ordered.php | 2 + .../Component/Listing/Renderer.php | 92 +++++++++---------- .../Listing/TableColumnContextRenderer.php | 80 ++++++++++++++++ .../Component/Listing/Unordered.php | 2 + .../Component/Table/Column/Factory.php | 4 +- .../Column/{LinkListing.php => Listing.php} | 7 +- .../UI/src/Implementation/Render/FSLoader.php | 6 ++ .../Column/{LinkListing => Listing}/base.php | 35 ++++--- .../default/Listing/tpl.ordered.html | 4 +- .../Listing/tpl.table_column_context.html | 10 ++ .../default/Listing/tpl.unordered.html | 4 +- components/ILIAS/UI/tests/Base.php | 10 ++ .../Table/Column/ColumnFactoryTest.php | 3 +- .../Component/Table/Column/ColumnTest.php | 62 ++++--------- components/ILIAS/UI/tests/InitUIFramework.php | 10 ++ .../ILIAS/UI/tests/Renderer/FSLoaderTest.php | 3 + 32 files changed, 475 insertions(+), 131 deletions(-) create mode 100644 components/ILIAS/UI/resources/js/Listing/dist/listing.min.js create mode 100755 components/ILIAS/UI/resources/js/Listing/rollup.config.js create mode 100644 components/ILIAS/UI/resources/js/Listing/src/createExpandableList.js create mode 100755 components/ILIAS/UI/resources/js/Listing/src/listing.js rename components/ILIAS/UI/src/Component/Table/Column/{LinkListing.php => Listing.php} (94%) create mode 100644 components/ILIAS/UI/src/Implementation/Component/Listing/ListingRendererFactory.php create mode 100644 components/ILIAS/UI/src/Implementation/Component/Listing/TableColumnContextRenderer.php rename components/ILIAS/UI/src/Implementation/Component/Table/Column/{LinkListing.php => Listing.php} (81%) rename components/ILIAS/UI/src/examples/Table/Column/{LinkListing => Listing}/base.php (69%) create mode 100644 components/ILIAS/UI/src/templates/default/Listing/tpl.table_column_context.html diff --git a/components/ILIAS/Calendar/classes/ConsultationHours/BookingTableGUI.php b/components/ILIAS/Calendar/classes/ConsultationHours/BookingTableGUI.php index 594321c5c83f..ff028eb872d0 100644 --- a/components/ILIAS/Calendar/classes/ConsultationHours/BookingTableGUI.php +++ b/components/ILIAS/Calendar/classes/ConsultationHours/BookingTableGUI.php @@ -207,15 +207,15 @@ protected function getColumns(): array 'booking_participant' => $this->ui_factory ->table() ->column() - ->linkListing($this->lng->txt('cal_ch_booking_participants')), + ->listing($this->lng->txt('cal_ch_booking_participants')), 'booking_comment' => $this->ui_factory ->table() ->column() - ->linkListing($this->lng->txt('cal_ch_booking_col_comments')), + ->listing($this->lng->txt('cal_ch_booking_col_comments')), 'booking_location' => $this->ui_factory ->table() ->column() - ->linkListing($this->lng->txt('cal_ch_target_object')) + ->listing($this->lng->txt('cal_ch_target_object')) ]; } diff --git a/components/ILIAS/Course/classes/Grouping/Table/GroupingHandler.php b/components/ILIAS/Course/classes/Grouping/Table/GroupingHandler.php index 97984f5fcfe3..7df6dd0e8543 100755 --- a/components/ILIAS/Course/classes/Grouping/Table/GroupingHandler.php +++ b/components/ILIAS/Course/classes/Grouping/Table/GroupingHandler.php @@ -95,7 +95,7 @@ protected function buildColumns(): array self::COL_DESCRIPTION => $f->text($this->lng->txt('description'))->withIsSortable(true), self::COL_SOURCE => $f->link($this->lng->txt('groupings_source'))->withIsSortable(true), self::COL_UNIQUE_FIELD => $f->text($this->lng->txt('unambiguousness'))->withIsSortable(true), - self::COL_ASSIGNED_OBJS => $f->linkListing($this->lng->txt('groupings_assigned_obj_' . $type))->withIsSortable(true) + self::COL_ASSIGNED_OBJS => $f->listing($this->lng->txt('groupings_assigned_obj_' . $type))->withIsSortable(true) ]; } diff --git a/components/ILIAS/Repository/Service/Table/TableAdapterGUI.php b/components/ILIAS/Repository/Service/Table/TableAdapterGUI.php index 15829b48e271..14e941d83a63 100755 --- a/components/ILIAS/Repository/Service/Table/TableAdapterGUI.php +++ b/components/ILIAS/Repository/Service/Table/TableAdapterGUI.php @@ -137,7 +137,7 @@ public function linkListingColumn( string $title, bool $sortable = false ): self { - $column = $this->ui->factory()->table()->column()->linkListing($title)->withIsSortable($sortable); + $column = $this->ui->factory()->table()->column()->listing($title)->withIsSortable($sortable); $this->addColumn($key, $column); return $this; } diff --git a/components/ILIAS/Skill/Table/classes/class.AssignMaterialsTable.php b/components/ILIAS/Skill/Table/classes/class.AssignMaterialsTable.php index f13a28acb197..d78033815bad 100755 --- a/components/ILIAS/Skill/Table/classes/class.AssignMaterialsTable.php +++ b/components/ILIAS/Skill/Table/classes/class.AssignMaterialsTable.php @@ -100,7 +100,7 @@ protected function getColumns(): array ->withIsSortable(false), "description" => $this->ui_fac->table()->column()->text($this->lng->txt("description")) ->withIsSortable(false), - "resources" => $this->ui_fac->table()->column()->linkListing($this->lng->txt("skmg_materials")) + "resources" => $this->ui_fac->table()->column()->listing($this->lng->txt("skmg_materials")) ->withIsSortable(false) ]; diff --git a/components/ILIAS/UI/UI.php b/components/ILIAS/UI/UI.php index ca489d6225c0..1fd13c1e888f 100644 --- a/components/ILIAS/UI/UI.php +++ b/components/ILIAS/UI/UI.php @@ -551,6 +551,16 @@ public function init( $use[UI\HelpTextRetriever::class], $internal[UI\Implementation\Component\Input\UploadLimitResolver::class], ), + new UI\Implementation\Component\Listing\ListingRendererFactory( + $use[UI\Implementation\FactoryInternal::class], + $internal[UI\Implementation\Render\TemplateFactory::class], + $use[Language\Language::class], + $internal[UI\Implementation\Render\JavaScriptBinding::class], + $use[UI\Implementation\Render\ImagePathResolver::class], + $pull[Data\Factory::class], + $use[UI\HelpTextRetriever::class], + $internal[UI\Implementation\Component\Input\UploadLimitResolver::class], + ), ) ) ); @@ -593,6 +603,8 @@ public function init( new Component\Resource\ComponentJS($this, "js/Input/Field/input.js"); $contribute[Component\Resource\PublicAsset::class] = fn() => new Component\Resource\ComponentJS($this, "js/Item/dist/notification.js"); + $contribute[Component\Resource\PublicAsset::class] = fn() => + new Component\Resource\ComponentJS($this, "js/Listing/dist/listing.min.js"); $contribute[Component\Resource\PublicAsset::class] = fn() => new Component\Resource\ComponentJS($this, "js/MainControls/dist/mainbar.js"); $contribute[Component\Resource\PublicAsset::class] = fn() => diff --git a/components/ILIAS/UI/resources/js/Listing/dist/listing.min.js b/components/ILIAS/UI/resources/js/Listing/dist/listing.min.js new file mode 100644 index 000000000000..eb2203dc7cb3 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Listing/dist/listing.min.js @@ -0,0 +1,15 @@ +/** + * This file is part of ILIAS, a powerful learning management system + * published by ILIAS open source e-Learning e.V. + * + * ILIAS is licensed with the GPL-3.0, + * see https://www.gnu.org/licenses/gpl-3.0.en.html + * You should have received a copy of said license along with the + * source code, too. + * + * If this is not the case or you just want to try ILIAS, you'll find + * us at: + * https://www.ilias.de + * https://github.com/ILIAS-eLearning + */ +!function(t,e){"use strict";t.UI=t.UI||{},t.UI.Listing={createExpandableList:i=>function(t,e){const i=e.parentElement.querySelector(`[aria-controls="${e.id}"]`);if(!i)throw new Error("Could not find button associated with list.");if(!e.hasAttribute("data-max-items"))throw new Error("Could not find max items attribute.");const a=parseInt(e.getAttribute("data-max-items"),10),n=e.querySelectorAll("li");i.addEventListener("click",()=>{!function(t,e,i,a){const n=e.hasAttribute("aria-expanded")&&"true"===e.getAttribute("aria-expanded");i.forEach((t,e)=>{e>a-1&&(n?t.classList.replace("visible","hidden"):t.classList.replace("hidden","visible"))}),n?(e.setAttribute("aria-expanded","false"),e.textContent=t.txt("show_more")):(e.setAttribute("aria-expanded","true"),e.textContent=t.txt("show_less"))}(t,i,n,a)})}({txt:e=>t.Language.txt(e)},e.getElementById(i))}}(il,document); diff --git a/components/ILIAS/UI/resources/js/Listing/rollup.config.js b/components/ILIAS/UI/resources/js/Listing/rollup.config.js new file mode 100755 index 000000000000..bd092843e631 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Listing/rollup.config.js @@ -0,0 +1,43 @@ +/** + * This file is part of ILIAS, a powerful learning management system + * published by ILIAS open source e-Learning e.V. + * + * ILIAS is licensed with the GPL-3.0, + * see https://www.gnu.org/licenses/gpl-3.0.en.html + * You should have received a copy of said license along with the + * source code, too. + * + * If this is not the case or you just want to try ILIAS, you'll find + * us at: + * https://www.ilias.de + * https://github.com/ILIAS-eLearning + */ + +import terser from '@rollup/plugin-terser'; +import copyright from '../../../../../../scripts/Copyright-Checker/copyright.js'; +import preserveCopyright from '../../../../../../scripts/Copyright-Checker/preserveCopyright.js'; + +export default { + input: './src/listing.js', + external: [ + 'ilias', + 'document', + ], + output: { + // file: '../../../../../../public/assets/js/listing.min.js', + file: './dist/listing.min.js', + format: 'iife', + banner: copyright, + globals: { + ilias: 'il', + document: 'document', + }, + plugins: [ + terser({ + format: { + comments: preserveCopyright, + }, + }), + ], + }, +}; diff --git a/components/ILIAS/UI/resources/js/Listing/src/createExpandableList.js b/components/ILIAS/UI/resources/js/Listing/src/createExpandableList.js new file mode 100644 index 000000000000..a2008ce771f6 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Listing/src/createExpandableList.js @@ -0,0 +1,74 @@ +/** + * This file is part of ILIAS, a powerful learning management system + * published by ILIAS open source e-Learning e.V. + * + * ILIAS is licensed with the GPL-3.0, + * see https://www.gnu.org/licenses/gpl-3.0.en.html + * You should have received a copy of said license along with the + * source code, too. + * + * If this is not the case or you just want to try ILIAS, you'll find + * us at: + * https://www.ilias.de + * https://github.com/ILIAS-eLearning + * + * @author Thibeau Fuhrer + */ + +/** + * @param {{ txt: function(string): string }} + * @param {HTMLButtonElement} button + * @param {HTMLLIElement[]} listItems + * @param {number} maxItemCount + */ +function toggleListItems( + language, + button, + listItems, + maxItemCount, +) { + const isExpanded = button.hasAttribute('aria-expanded') + && button.getAttribute('aria-expanded') === 'true'; + + listItems.forEach((item, index) => { + if (index > (maxItemCount - 1)) { + if (isExpanded) { + item.classList.replace('visible', 'hidden'); + } else { + item.classList.replace('hidden', 'visible'); + } + } + }); + if (isExpanded) { + button.setAttribute('aria-expanded', 'false'); + button.textContent = language.txt('show_more'); + } else { + button.setAttribute('aria-expanded', 'true'); + button.textContent = language.txt('show_less'); + } +} + +/** + * @param {{ txt: function(string): string }} + * @param {HTMLUListElement|HTMLOListElement} list + */ +export default function createExpandableList(language, list) { + const button = list.parentElement.querySelector(`[aria-controls="${list.id}"]`); + if (!button) { + throw new Error('Could not find button associated with list.'); + } + if (!list.hasAttribute('data-max-items')) { + throw new Error('Could not find max items attribute.'); + } + const maxItemCount = parseInt(list.getAttribute('data-max-items'), 10); + const listItems = list.querySelectorAll('li'); + + button.addEventListener('click', () => { + toggleListItems( + language, + button, + listItems, + maxItemCount, + ); + }); +} diff --git a/components/ILIAS/UI/resources/js/Listing/src/listing.js b/components/ILIAS/UI/resources/js/Listing/src/listing.js new file mode 100755 index 000000000000..5802d3f6c917 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Listing/src/listing.js @@ -0,0 +1,27 @@ +/** + * This file is part of ILIAS, a powerful learning management system + * published by ILIAS open source e-Learning e.V. + * + * ILIAS is licensed with the GPL-3.0, + * see https://www.gnu.org/licenses/gpl-3.0.en.html + * You should have received a copy of said license along with the + * source code, too. + * + * If this is not the case or you just want to try ILIAS, you'll find + * us at: + * https://www.ilias.de + * https://github.com/ILIAS-eLearning + */ + +import il from 'ilias'; +import document from 'document'; +import createExpandableList from './createExpandableList.js'; + +il.UI = il.UI || {}; + +il.UI.Listing = { + createExpandableList: (id) => createExpandableList( + { txt: (key) => il.Language.txt(key) }, + document.getElementById(id), + ), +}; diff --git a/components/ILIAS/UI/src/Component/Listing/Inline.php b/components/ILIAS/UI/src/Component/Listing/Inline.php index a9e05e21d5cc..d38ebcc21786 100644 --- a/components/ILIAS/UI/src/Component/Listing/Inline.php +++ b/components/ILIAS/UI/src/Component/Listing/Inline.php @@ -19,6 +19,8 @@ namespace ILIAS\UI\Component\Listing; -interface Inline extends Listing +use ILIAS\UI\Component\JavaScriptBindable; + +interface Inline extends Listing, JavaScriptBindable { } diff --git a/components/ILIAS/UI/src/Component/Listing/Ordered.php b/components/ILIAS/UI/src/Component/Listing/Ordered.php index b17dc542cb46..004147c43641 100755 --- a/components/ILIAS/UI/src/Component/Listing/Ordered.php +++ b/components/ILIAS/UI/src/Component/Listing/Ordered.php @@ -20,10 +20,12 @@ namespace ILIAS\UI\Component\Listing; +use ILIAS\UI\Component\JavaScriptBindable; + /** * Interface Ordered * @package ILIAS\UI\Component\Listing */ -interface Ordered extends Listing +interface Ordered extends Listing, JavaScriptBindable { } diff --git a/components/ILIAS/UI/src/Component/Listing/Unordered.php b/components/ILIAS/UI/src/Component/Listing/Unordered.php index c58603a59bf4..49e3bb9752d9 100755 --- a/components/ILIAS/UI/src/Component/Listing/Unordered.php +++ b/components/ILIAS/UI/src/Component/Listing/Unordered.php @@ -20,10 +20,12 @@ namespace ILIAS\UI\Component\Listing; +use ILIAS\UI\Component\JavaScriptBindable; + /** * Interface Unordered * @package ILIAS\UI\Component\Listing */ -interface Unordered extends Listing +interface Unordered extends Listing, JavaScriptBindable { } diff --git a/components/ILIAS/UI/src/Component/Table/Column/Factory.php b/components/ILIAS/UI/src/Component/Table/Column/Factory.php index d97c6b92ab7f..61f211de251b 100755 --- a/components/ILIAS/UI/src/Component/Table/Column/Factory.php +++ b/components/ILIAS/UI/src/Component/Table/Column/Factory.php @@ -136,12 +136,21 @@ public function link(string $title): Link; * --- * description: * purpose: > - * The LinkListing Column features an Ordered or Unordered Listing of Standard Links. - * - * --- - * @return \ILIAS\UI\Component\Table\Column\LinkListing + * The Listing Column is used for representing lists inside a table. To + * account for large lists, the Listing Column caps the amount of items + * which are initially visible and provides a toggle to show/hide them. + * composition: > + * The Listing Column either consists of an Unordered or Ordered Listing. + * If the Listing contains many items, some of them will be initially + * hidden and a Shy Button appears below the Listing. + * effect: > + * Clicking the Shy Button will toggle the visibility of initially hidden + * items of the Listing. + * --- + * @param string $title + * @return \ILIAS\UI\Component\Table\Column\Listing */ - public function linkListing(string $title): LinkListing; + public function listing(string $title): Listing; /** * --- diff --git a/components/ILIAS/UI/src/Component/Table/Column/LinkListing.php b/components/ILIAS/UI/src/Component/Table/Column/Listing.php similarity index 94% rename from components/ILIAS/UI/src/Component/Table/Column/LinkListing.php rename to components/ILIAS/UI/src/Component/Table/Column/Listing.php index b02c08c1cc01..fa80d9810da6 100755 --- a/components/ILIAS/UI/src/Component/Table/Column/LinkListing.php +++ b/components/ILIAS/UI/src/Component/Table/Column/Listing.php @@ -20,6 +20,6 @@ namespace ILIAS\UI\Component\Table\Column; -interface LinkListing extends Column +interface Listing extends Column { } diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Inline.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Inline.php index bf6b3ebc84fc..87ac67c4aba4 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Inline.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Inline.php @@ -20,7 +20,9 @@ namespace ILIAS\UI\Implementation\Component\Listing; use ILIAS\UI\Component as C; +use ILIAS\UI\Implementation\Component\JavaScriptBindable; class Inline extends Listing implements C\Listing\Inline { + use JavaScriptBindable; } diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/ListingRendererFactory.php b/components/ILIAS/UI/src/Implementation/Component/Listing/ListingRendererFactory.php new file mode 100644 index 000000000000..8ffbb8a2c766 --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/ListingRendererFactory.php @@ -0,0 +1,54 @@ + + */ +class ListingRendererFactory extends DefaultRendererFactory +{ + /** @var string[] cannonical names of table components */ + protected const array TABLE_COLUMN_CONTEXTS = [ + 'OrderingRowTable', + 'DataRowTable', + ]; + + public function getRendererInContext(Component $component, array $contexts): ComponentRenderer + { + if (!empty(array_intersect(self::TABLE_COLUMN_CONTEXTS, $contexts))) { + return new TableColumnContextRenderer( + $this->ui_factory, + $this->tpl_factory, + $this->lng, + $this->js_binding, + $this->image_path_resolver, + $this->data_factory, + $this->help_text_retriever, + $this->upload_limit_resolver, + ); + } + + return parent::getRendererInContext($component, $contexts); + } +} diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Ordered.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Ordered.php index 6bbda158f88a..6d1f2bc3c6e7 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Ordered.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Ordered.php @@ -21,6 +21,7 @@ namespace ILIAS\UI\Implementation\Component\Listing; use ILIAS\UI\Component as C; +use ILIAS\UI\Implementation\Component\JavaScriptBindable; /** * Class Listing @@ -28,4 +29,5 @@ */ class Ordered extends Listing implements C\Listing\Ordered { + use JavaScriptBindable; } diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Renderer.php index 095a93b48c8b..da00b21b1f3c 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Renderer.php @@ -21,9 +21,9 @@ namespace ILIAS\UI\Implementation\Component\Listing; use ILIAS\UI\Implementation\Render\AbstractComponentRenderer; -use ILIAS\UI\Implementation\Render\Template; use ILIAS\UI\Renderer as RendererInterface; -use ILIAS\UI\Component\Component; +use ILIAS\UI\Component; +use ILIAS\UI\Implementation\Render\Template; /** * Class Renderer @@ -37,28 +37,27 @@ class Renderer extends AbstractComponentRenderer /** * @inheritdocs */ - public function render(Component $component, RendererInterface $default_renderer): string + public function render(Component\Component $component, RendererInterface $default_renderer): string { if ($component instanceof Descriptive) { - return $this->renderDescriptive($component, $default_renderer); + return $this->renderDescriptiveList($component, $default_renderer); } + if ($component instanceof Property) { - return $this->renderProperty($component, $default_renderer); - } - if ($component instanceof Ordered) { - return $this->renderOrdered($component, $default_renderer); - } - if ($component instanceof Unordered) { - return $this->renderUnordered($component, $default_renderer); + return $this->renderPropertyList($component, $default_renderer); } - if ($component instanceof Inline) { - return $this->renderInline($component, $default_renderer); + + if ($component instanceof Unordered || + $component instanceof Ordered || + $component instanceof Inline + ) { + return $this->renderList($component, $default_renderer); } $this->cannotHandleComponent($component); } - protected function renderDescriptive( + protected function renderDescriptiveList( Descriptive $component, RendererInterface $default_renderer ): string { @@ -81,51 +80,36 @@ protected function renderDescriptive( return $tpl->get(); } - protected function renderOrdered(Ordered $component, RendererInterface $default_renderer): string - { - $tpl = $this->getTemplate("tpl.ordered.html", true, true); - - $tpl = $this->fillItems($tpl, $component, $default_renderer); - - return $tpl->get(); - } - - protected function renderUnordered(Unordered $component, RendererInterface $default_renderer): string - { - $tpl = $this->getTemplate("tpl.unordered.html", true, true); - - $tpl = $this->fillItems($tpl, $component, $default_renderer); - - return $tpl->get(); - } - - protected function renderInline(Inline $component, RendererInterface $default_renderer): string + protected function renderList(Unordered|Ordered|Inline $component, RendererInterface $default_renderer): string { - $tpl = $this->getTemplate("tpl.inline.html", true, true); - - $tpl = $this->fillItems($tpl, $component, $default_renderer); - - return $tpl->get(); - } + if ($component instanceof Unordered) { + $tpl_name = "tpl.unordered.html"; + } elseif ($component instanceof Ordered) { + $tpl_name = "tpl.ordered.html"; + } elseif ($component instanceof Inline) { + $tpl_name = "tpl.inline.html"; + } else { + $this->cannotHandleComponent($component); + } - protected function fillItems(Template $tpl, Listing $component, RendererInterface $default_renderer): Template - { - $items = $component->getItems(); + $tpl = $this->getTemplate($tpl_name, true, true); - foreach ($items as $item) { + foreach ($component->getItems() as $item) { $tpl->setCurrentBlock("item"); - if ($item instanceof Component) { - $tpl->setVariable("ITEM", $default_renderer->render($item)); - } else { + if (is_string($item)) { $tpl->setVariable("ITEM", $item); + } else { + $tpl->setVariable("ITEM", $default_renderer->render($item)); } $tpl->parseCurrentBlock(); } - return $tpl; + $this->bindAndApplyJavaScript($component, $tpl); + + return $tpl->get(); } - protected function renderProperty( + protected function renderPropertyList( Property $component, RendererInterface $default_renderer ): string { @@ -134,7 +118,7 @@ protected function renderProperty( foreach ($component->getItems() as [$label, $value, $show_label]) { $tpl->setCurrentBlock("property"); if ($show_label) { - if ($label instanceof Component) { + if ($label instanceof Component\Component) { $tpl->setVariable('LABEL', $default_renderer->render($label)); } else { $tpl->setVariable('LABEL', $this->convertSpecialCharacters($label)); @@ -148,11 +132,19 @@ protected function renderProperty( $tpl->parseCurrentBlock(); } elseif (is_string($value)) { $tpl->setVariable("SHORT_VALUE", $this->convertSpecialCharacters($value)); - } elseif ($value instanceof Component) { + } elseif ($value instanceof Component\Component) { $tpl->setVariable("SHORT_VALUE", $default_renderer->render($value)); } $tpl->parseCurrentBlock(); } return $tpl->get(); } + + protected function bindAndApplyJavaScript(Component\JavaScriptBindable $component, Template $template): void + { + $id = $this->bindJavaScript($component); + if (null !== $id) { + $template->setVariable('ID', $id); + } + } } diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/TableColumnContextRenderer.php b/components/ILIAS/UI/src/Implementation/Component/Listing/TableColumnContextRenderer.php new file mode 100644 index 000000000000..9b486c473636 --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/TableColumnContextRenderer.php @@ -0,0 +1,80 @@ + + */ +class TableColumnContextRenderer extends Renderer +{ + protected const int LIST_DISPLAY_LIMIT = 3; + + public function registerResources(ResourceRegistry $registry): void + { + $registry->register('assets/js/listing.min.js'); + } + + protected function renderList(Ordered|Unordered|Inline $component, RendererInterface $default_renderer): string + { + if ($component instanceof Inline || self::LIST_DISPLAY_LIMIT >= count($component->getItems())) { + return parent::renderList($component, $default_renderer); + } + + $template = $this->getTemplate('tpl.table_column_context.html', true, true); + $template->setVariable('DISPLAY_LIMIT', self::LIST_DISPLAY_LIMIT); + $template->setVariable('SHOW_MORE_LABEL', $this->txt('show_more')); + + if ($component instanceof Ordered) { + $template->setVariable('LIST_TYPE', 'ol'); + } else { + $template->setVariable('LIST_TYPE', 'ul'); + } + + // array_values() ensures we can use $index for count + foreach (array_values($component->getItems()) as $index => $item) { + $template->setCurrentBlock("item"); + if (is_string($item)) { + $template->setVariable("ITEM", $item); + } else { + $template->setVariable("ITEM", $default_renderer->render($item)); + } + if (self::LIST_DISPLAY_LIMIT > $index) { + $template->setVariable('VISIBILITY', 'visible'); + } else { + $template->setVariable('VISIBILITY', 'hidden'); + } + $template->parseCurrentBlock(); + } + + $enriched_component = $component->withAdditionalOnLoadCode( + static fn($id) => "il.UI.Listing.createExpandableList('$id');", + ); + + $this->bindAndApplyJavaScript($enriched_component, $template); + + $this->toJS('show_more'); + $this->toJS('show_less'); + + return $template->get(); + } +} diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Unordered.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Unordered.php index d983880eebf6..099c75b7c88d 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Unordered.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Unordered.php @@ -21,6 +21,7 @@ namespace ILIAS\UI\Implementation\Component\Listing; use ILIAS\UI\Component as C; +use ILIAS\UI\Implementation\Component\JavaScriptBindable; /** * Class Listing @@ -28,4 +29,5 @@ */ class Unordered extends Listing implements C\Listing\Unordered { + use JavaScriptBindable; } diff --git a/components/ILIAS/UI/src/Implementation/Component/Table/Column/Factory.php b/components/ILIAS/UI/src/Implementation/Component/Table/Column/Factory.php index 7d8aaeb666e1..c46f15baf895 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Table/Column/Factory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Table/Column/Factory.php @@ -81,9 +81,9 @@ public function link(string $title): Link return new Link($this->lng, $title); } - public function linkListing(string $title): LinkListing + public function listing(string $title): Listing { - return new LinkListing($this->lng, $title); + return new Listing($this->lng, $title); } public function breadcrumb(string $title): I\Breadcrumb diff --git a/components/ILIAS/UI/src/Implementation/Component/Table/Column/LinkListing.php b/components/ILIAS/UI/src/Implementation/Component/Table/Column/Listing.php similarity index 81% rename from components/ILIAS/UI/src/Implementation/Component/Table/Column/LinkListing.php rename to components/ILIAS/UI/src/Implementation/Component/Table/Column/Listing.php index 3d672128a001..64e42b060507 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Table/Column/LinkListing.php +++ b/components/ILIAS/UI/src/Implementation/Component/Table/Column/Listing.php @@ -21,19 +21,16 @@ namespace ILIAS\UI\Implementation\Component\Table\Column; use ILIAS\UI\Component\Table\Column as C; -use ILIAS\UI\Component\Link\Standard; use ILIAS\UI\Component\Listing\Ordered; use ILIAS\UI\Component\Listing\Unordered; use ILIAS\UI\Component\Component; -class LinkListing extends Column implements C\LinkListing +class Listing extends Column implements C\Listing { public function format($value): string|Component { $listing = $this->toArray($value); - $this->checkArgListElements("value", $listing, [Ordered::class, Unordered::class]); - $listing_items = $value->getItems(); - $this->checkArgListElements("list items", $listing_items, Standard::class); + $this->checkArgListElements('value', $listing, [Ordered::class, Unordered::class]); return $value; } diff --git a/components/ILIAS/UI/src/Implementation/Render/FSLoader.php b/components/ILIAS/UI/src/Implementation/Render/FSLoader.php index e7e5ff088705..851679081989 100755 --- a/components/ILIAS/UI/src/Implementation/Render/FSLoader.php +++ b/components/ILIAS/UI/src/Implementation/Render/FSLoader.php @@ -28,6 +28,7 @@ use ILIAS\UI\Implementation\Component\MessageBox\MessageBox; use ILIAS\UI\Implementation\Component\Input\Container\Form\Form; use ILIAS\UI\Implementation\Component\Menu\Menu; +use ILIAS\UI\Implementation\Component\Listing\Listing; /** * Loads renderers for components from the file system. @@ -51,6 +52,7 @@ public function __construct( private RendererFactory $message_box_renderer_factory, private RendererFactory $form_renderer_factory, private RendererFactory $menu_renderer_factory, + private RendererFactory $listing_renderer_factory, ) { } @@ -84,6 +86,10 @@ public function getRendererFactoryFor(Component $component): RendererFactory if ($component instanceof Button) { return $this->button_renderer_factory; } + if ($component instanceof Listing) { + return $this->listing_renderer_factory; + } + return $this->default_renderer_factory; } } diff --git a/components/ILIAS/UI/src/examples/Table/Column/LinkListing/base.php b/components/ILIAS/UI/src/examples/Table/Column/Listing/base.php similarity index 69% rename from components/ILIAS/UI/src/examples/Table/Column/LinkListing/base.php rename to components/ILIAS/UI/src/examples/Table/Column/Listing/base.php index 5fd76c9f9538..7dafa2327fa1 100755 --- a/components/ILIAS/UI/src/examples/Table/Column/LinkListing/base.php +++ b/components/ILIAS/UI/src/examples/Table/Column/Listing/base.php @@ -18,7 +18,7 @@ declare(strict_types=1); -namespace ILIAS\UI\examples\Table\Column\LinkListing; +namespace ILIAS\UI\examples\Table\Column\Listing; use ILIAS\UI\Component\Table as I; use ILIAS\Data\Range; @@ -32,28 +32,39 @@ */ function base(): string { + /** @var \ILIAS\DI\Container $DIC */ global $DIC; $f = $DIC->ui()->factory(); $r = $DIC->ui()->renderer(); $columns = [ - 'l1' => $f->table()->column()->linkListing("a link list column") + 'l1' => $f->table()->column()->listing('A list column') ]; - $some_link = $f->link()->standard('ILIAS Homepage', 'http://www.ilias.de'); - $some_linklisting = $f->listing()->unordered([$some_link, $some_link, $some_link]); - - $dummy_records = [ - ['l1' => $some_linklisting], - ['l1' => $some_linklisting] + $records = [ + [ + 'l1' => $f->listing()->unordered([ + 'Apples', + 'Oranges', + 'Bananas', + 'Pears' + ]) + ], + [ + 'l1' => $f->listing()->unordered([ + 'Bun', + 'Croissant', + 'Pumpernickel' + ]) + ] ]; - $data_retrieval = new class ($dummy_records) implements I\DataRetrieval { + $data_retrieval = new class ($records) implements I\DataRetrieval { protected array $records; - public function __construct(array $dummy_records) + public function __construct(array $records) { - $this->records = $dummy_records; + $this->records = $records; } public function getRows( @@ -80,7 +91,7 @@ public function getTotalRowCount( } }; - $table = $f->table()->data($data_retrieval, 'Link List Columns', $columns) + $table = $f->table()->data($data_retrieval, 'List Columns', $columns) ->withRequest($DIC->http()->request()); return $r->render($table); } diff --git a/components/ILIAS/UI/src/templates/default/Listing/tpl.ordered.html b/components/ILIAS/UI/src/templates/default/Listing/tpl.ordered.html index c1a592268983..f0797ccc861e 100755 --- a/components/ILIAS/UI/src/templates/default/Listing/tpl.ordered.html +++ b/components/ILIAS/UI/src/templates/default/Listing/tpl.ordered.html @@ -1,5 +1,5 @@ -
    +id="{ID}">
  1. {ITEM}
  2. -
\ No newline at end of file + diff --git a/components/ILIAS/UI/src/templates/default/Listing/tpl.table_column_context.html b/components/ILIAS/UI/src/templates/default/Listing/tpl.table_column_context.html new file mode 100644 index 000000000000..0cbb547f6c7e --- /dev/null +++ b/components/ILIAS/UI/src/templates/default/Listing/tpl.table_column_context.html @@ -0,0 +1,10 @@ +
+ <{LIST_TYPE} id="{ID}" data-max-items="{DISPLAY_LIMIT}"> + +
  • {ITEM}
  • + + + +
    diff --git a/components/ILIAS/UI/src/templates/default/Listing/tpl.unordered.html b/components/ILIAS/UI/src/templates/default/Listing/tpl.unordered.html index 594abfe9a720..3239b2ed9924 100755 --- a/components/ILIAS/UI/src/templates/default/Listing/tpl.unordered.html +++ b/components/ILIAS/UI/src/templates/default/Listing/tpl.unordered.html @@ -1,5 +1,5 @@ -
      +id="{ID}">
    • {ITEM}
    • -
    \ No newline at end of file + diff --git a/components/ILIAS/UI/tests/Base.php b/components/ILIAS/UI/tests/Base.php index a82b6f9d7ad7..0412bff2ac42 100755 --- a/components/ILIAS/UI/tests/Base.php +++ b/components/ILIAS/UI/tests/Base.php @@ -479,6 +479,16 @@ public function getDefaultRenderer( $data_factory, $help_text_retriever, $this->getUploadLimitResolver(), + ), + new I\Listing\ListingRendererFactory( + $ui_factory, + $tpl_factory, + $lng, + $js_binding, + $image_path_resolver, + $data_factory, + $help_text_retriever, + $this->getUploadLimitResolver(), ) ) ) diff --git a/components/ILIAS/UI/tests/Component/Table/Column/ColumnFactoryTest.php b/components/ILIAS/UI/tests/Component/Table/Column/ColumnFactoryTest.php index 8d85257fbf18..e916452323aa 100755 --- a/components/ILIAS/UI/tests/Component/Table/Column/ColumnFactoryTest.php +++ b/components/ILIAS/UI/tests/Component/Table/Column/ColumnFactoryTest.php @@ -37,6 +37,7 @@ class ColumnFactoryTest extends AbstractFactoryTestCase "link" => ["context" => false, "rules" => false], "linkListing" => ["context" => false, "rules" => false], "breadcrumb" => ["context" => false, "rules" => false], + "listing" => ["context" => false, "rules" => false] ]; public static string $factory_title = 'ILIAS\\UI\\Component\\Table\\Column\\Factory'; @@ -66,7 +67,7 @@ public static function getColumnTypeProvider(): array [static fn($f) => [Column\StatusIcon::class, $f->statusIcon("")]], [static fn($f) => [Column\Link::class, $f->link("")]], [static fn($f) => [Column\EMail::class, $f->eMail("")]], - [static fn($f) => [Column\LinkListing::class, $f->linkListing("")]] + [static fn($f) => [Column\Listing::class, $f->listing("")]] ]; } diff --git a/components/ILIAS/UI/tests/Component/Table/Column/ColumnTest.php b/components/ILIAS/UI/tests/Component/Table/Column/ColumnTest.php index 477cc2021d6a..ab88d3f0a3dc 100755 --- a/components/ILIAS/UI/tests/Component/Table/Column/ColumnTest.php +++ b/components/ILIAS/UI/tests/Component/Table/Column/ColumnTest.php @@ -143,45 +143,41 @@ public function __construct() }; return [ [ - 'column' => new Column\LinkListing($lng, ''), - 'value' => new Listing\Unordered([(new Link\Standard('label', '#')),(new Link\Standard('label', '#'))]), + 'column' => new Column\Link($lng, ''), + 'value' => new Link\Standard('label', '#'), 'ok' => true ], [ - 'column' => new Column\LinkListing($lng, ''), - 'value' => new Listing\Unordered(['string', 'string']), + 'column' => new Column\Link($lng, ''), + 'value' => 'some string', 'ok' => false ], [ - 'column' => new Column\LinkListing($lng, ''), - 'value' => new Listing\Ordered([(new Link\Standard('label', '#')),(new Link\Standard('label', '#'))]), + 'column' => new Column\StatusIcon($lng, ''), + 'value' => new StandardIcon('', '', 'small', false), 'ok' => true ], [ - 'column' => new Column\LinkListing($lng, ''), - 'value' => 123, + 'column' => new Column\StatusIcon($lng, ''), + 'value' => 'some string', 'ok' => false ], [ - 'column' => new Column\Link($lng, ''), - 'value' => new Link\Standard('label', '#'), + 'column' => new Column\Listing($lng, ''), + 'value' => (new Listing\Ordered(['1', '2', '3'])), 'ok' => true ], [ - 'column' => new Column\Link($lng, ''), - 'value' => 'some string', - 'ok' => false - ], - [ - 'column' => new Column\StatusIcon($lng, ''), - 'value' => new StandardIcon('', '', 'small', false), + 'column' => new Column\Listing($lng, ''), + 'value' => (new Listing\Unordered(['1', '2', '3'])), 'ok' => true ], [ - 'column' => new Column\StatusIcon($lng, ''), - 'value' => 'some string', + 'column' => new Column\Listing($lng, ''), + 'value' => 123, 'ok' => false ], + ]; } @@ -191,42 +187,24 @@ public function testDataTableColumnAllowedFormats( mixed $value, bool $ok ): void { - if(! $ok) { + if (! $ok) { $this->expectException(\InvalidArgumentException::class); } $this->assertEquals($value, $column->format($value)); } - public function testDataTableColumnLinkListingFormat(): void + public function testDataTableColumnListingFormat(): void { - $col = new Column\LinkListing($this->lng, 'col'); + $col = new Column\Listing($this->lng, 'col'); $link = new Link\Standard('label', '#'); - $linklisting = new Listing\Unordered([$link, $link, $link]); + $linklisting = (new Listing\Unordered([$link, $link, $link])); $this->assertEquals($linklisting, $col->format($linklisting)); } - public function testDataTableColumnLinkListingFormatAcceptsOnlyLinkListings(): void - { - $this->expectException(\InvalidArgumentException::class); - $col = new Column\LinkListing($this->lng, 'col'); - $linklisting_invalid = new Link\Standard('label', '#'); - $this->assertEquals($linklisting_invalid, $col->format($linklisting_invalid)); - } - - public function testDataTableColumnLinkListingItemsFormatAcceptsOnlyLinks(): void - { - $this->expectException(\InvalidArgumentException::class); - $col = new Column\LinkListing($this->lng, 'col'); - $link = 'some string'; - $linklisting_invalid = new Listing\Unordered([$link, $link, $link]); - $this->assertEquals($linklisting_invalid, $col->format($linklisting_invalid)); - } - public function testDataTableColumnCustomOrderingLabels(): void { - $col = (new Column\LinkListing($this->lng, 'col')) - ->withIsSortable(true) + $col = (new Column\Listing($this->lng, 'col')) ->withOrderingLabels( 'custom label ASC', 'custom label DESC', diff --git a/components/ILIAS/UI/tests/InitUIFramework.php b/components/ILIAS/UI/tests/InitUIFramework.php index 0ba2a1c745e5..b5425805726e 100755 --- a/components/ILIAS/UI/tests/InitUIFramework.php +++ b/components/ILIAS/UI/tests/InitUIFramework.php @@ -381,6 +381,16 @@ public function getRefreshIntervalInMs(): int $c["help.text_retriever"], $c["ui.upload_limit_resolver"] ), + new ILIAS\UI\Implementation\Component\Listing\ListingRendererFactory( + $c["ui.factory"], + $c["ui.template_factory"], + $c["lng"], + $c["ui.javascript_binding"], + $c["ui.pathresolver"], + $c["ui.data_factory"], + $c["help.text_retriever"], + $c["ui.upload_limit_resolver"], + ), ) ) ); diff --git a/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php b/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php index 689fbfd80d76..634360242a54 100755 --- a/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php +++ b/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php @@ -41,6 +41,7 @@ class FSLoaderTest extends TestCase protected RendererFactory & MockObject $message_box_renderer_factory; protected RendererFactory & MockObject $form_renderer_factory; protected RendererFactory & MockObject $menu_renderer_factory; + protected RendererFactory & MockObject $list_renderer_factory; protected FSLoader $fs_loader; @@ -52,6 +53,7 @@ protected function setUp(): void $this->message_box_renderer_factory = $this->createMock(RendererFactory::class); $this->form_renderer_factory = $this->createMock(RendererFactory::class); $this->menu_renderer_factory = $this->createMock(RendererFactory::class); + $this->list_renderer_factory = $this->createMock(RendererFactory::class); $this->fs_loader = new FSLoader( $this->default_renderer_factory, @@ -60,6 +62,7 @@ protected function setUp(): void $this->message_box_renderer_factory, $this->form_renderer_factory, $this->menu_renderer_factory, + $this->list_renderer_factory, ); parent::setUp();