diff --git a/components/ILIAS/LearningSequence/LearningSequence.php b/components/ILIAS/LearningSequence/LearningSequence.php index 41c40747a29b..44eec0330f4f 100644 --- a/components/ILIAS/LearningSequence/LearningSequence.php +++ b/components/ILIAS/LearningSequence/LearningSequence.php @@ -36,5 +36,8 @@ public function init( new \ilLearningSequenceSetupAgent( $pull[\ILIAS\Refinery\Factory::class] ); + + $contribute[Component\Resource\PublicAsset::class] = fn() => + new Component\Resource\ComponentJS($this, "js/lso_kiosk_rating.js"); } } diff --git a/components/ILIAS/LearningSequence/classes/GlobalScreen/class.ilLSViewLayoutProvider.php b/components/ILIAS/LearningSequence/classes/GlobalScreen/class.ilLSViewLayoutProvider.php index c4fcfb334864..0afcc87dd844 100755 --- a/components/ILIAS/LearningSequence/classes/GlobalScreen/class.ilLSViewLayoutProvider.php +++ b/components/ILIAS/LearningSequence/classes/GlobalScreen/class.ilLSViewLayoutProvider.php @@ -139,7 +139,9 @@ public function getPageBuilderDecorator(CalledContexts $screen_context_stack): ? } $exit = $this->data_collection->get(\ilLSPlayer::GS_DATA_LS_METABARCONTROLS)['exit']; - $label = $this->dic['lng']->txt('lso_player_viewmodelabel'); + + $label = $this->data_collection->get(\ilLSPlayer::GS_DATA_LS_TITLE) + ?? $this->dic['lng']->txt('lso_player_viewmodelabel'); $lnk = new URI($exit->getAction()); diff --git a/components/ILIAS/LearningSequence/classes/Player/LSControlBuilder.php b/components/ILIAS/LearningSequence/classes/Player/LSControlBuilder.php index 90e161e26adf..e71f37ee3d05 100755 --- a/components/ILIAS/LearningSequence/classes/Player/LSControlBuilder.php +++ b/components/ILIAS/LearningSequence/classes/Player/LSControlBuilder.php @@ -156,6 +156,9 @@ public function next(string $command, ?int $parameter = null): ControlBuilder throw new \LogicException("Only one next-control per view...", 1); } $label = $this->lng->txt('lso_player_next'); + if ($command === ilLSPlayer::LSO_CMD_FINISH) { + $label = $this->lng->txt('lso_player_finish'); + } $cmd = $this->url_builder->getHref($command, $parameter); $btn = $this->ui_factory->button()->standard($label, $cmd); if ($command === '') { diff --git a/components/ILIAS/LearningSequence/classes/Player/class.ilKioskPageRenderer.php b/components/ILIAS/LearningSequence/classes/Player/class.ilKioskPageRenderer.php index ab314a621b32..e537b5f77c50 100755 --- a/components/ILIAS/LearningSequence/classes/Player/class.ilKioskPageRenderer.php +++ b/components/ILIAS/LearningSequence/classes/Player/class.ilKioskPageRenderer.php @@ -81,11 +81,23 @@ public function buildToCSlate(LSTOCBuilder $toc, Icon $icon): Slate ); } + public function addOnLoadCode(string $code, int $batch = 2): void + { + $this->layout_meta_content->addOnloadCode($code, $batch); + } + + public function addJs(string $path, bool $add_version_number = false, int $batch = 2): void + { + $this->layout_meta_content->addJs($path, $add_version_number, $batch); + } + public function render( LSControlBuilder $control_builder, string $obj_title, + string $obj_description, Component $icon, - array $content + array $content, + string $obj_rating_html = '' ): string { $this->tpl->setVariable( "OBJECT_ICON", @@ -93,6 +105,17 @@ public function render( ); $this->tpl->setVariable("OBJECT_TITLE", $obj_title); + $obj_description = trim($obj_description); + if ($obj_description !== '') { + $this->tpl->setCurrentBlock('obj_desc'); + $this->tpl->setVariable( + 'OBJECT_DESCRIPTION', + nl2br(ilLegacyFormElementsUtil::prepareFormOutput($obj_description, true)) + ); + $this->tpl->parseCurrentBlock(); + } + $this->tpl->setVariable('OBJECT_RATING', $obj_rating_html); + $this->tpl->setVariable( "PLAYER_NAVIGATION", $this->ui_renderer->render([ diff --git a/components/ILIAS/LearningSequence/classes/Player/class.ilLSCurriculumBuilder.php b/components/ILIAS/LearningSequence/classes/Player/class.ilLSCurriculumBuilder.php index bd79f54a07e2..2ad1222489b9 100755 --- a/components/ILIAS/LearningSequence/classes/Player/class.ilLSCurriculumBuilder.php +++ b/components/ILIAS/LearningSequence/classes/Player/class.ilLSCurriculumBuilder.php @@ -45,8 +45,10 @@ public function __construct( $this->url_builder = $url_builder; } - public function getLearnerCurriculum(bool $with_action = false): ILIAS\UI\Component\Listing\Workflow\Linear - { + public function getLearnerCurriculum( + bool $with_action = false, + ?string $title = null + ): ILIAS\UI\Component\Listing\Workflow\Linear { $steps = []; $items = $this->ls_items->getItems(); foreach ($items as $item) { @@ -69,7 +71,7 @@ public function getLearnerCurriculum(bool $with_action = false): ILIAS\UI\Compon } $workflow = $this->ui_factory->listing()->workflow()->linear( - $this->lng->txt('curriculum'), + $title ?? $this->lng->txt('curriculum'), $steps ); diff --git a/components/ILIAS/LearningSequence/classes/Player/class.ilLSLearnerItemsQueries.php b/components/ILIAS/LearningSequence/classes/Player/class.ilLSLearnerItemsQueries.php index fd2a27a10c6f..8152f83d4669 100755 --- a/components/ILIAS/LearningSequence/classes/Player/class.ilLSLearnerItemsQueries.php +++ b/components/ILIAS/LearningSequence/classes/Player/class.ilLSLearnerItemsQueries.php @@ -43,7 +43,12 @@ public function __construct( $this->usr_id = $usr_id; } - public function hasItems() : bool + public function getLearningSequenceRefId(): int + { + return $this->ls_ref_id; + } + + public function hasItems(): bool { return count($this->getItems()) > 0; } diff --git a/components/ILIAS/LearningSequence/classes/Player/class.ilLSPlayer.php b/components/ILIAS/LearningSequence/classes/Player/class.ilLSPlayer.php index c2c5db27ab82..d2c730a4dcd5 100755 --- a/components/ILIAS/LearningSequence/classes/Player/class.ilLSPlayer.php +++ b/components/ILIAS/LearningSequence/classes/Player/class.ilLSPlayer.php @@ -40,6 +40,7 @@ class ilLSPlayer public const LSO_CMD_FINISH = 'lsofinish'; public const GS_DATA_LS_KIOSK_MODE = 'ls_kiosk_mode'; + public const GS_DATA_LS_TITLE = 'ls_title'; public const GS_DATA_LS_CONTENT = 'ls_content'; public const GS_DATA_LS_MAINBARCONTROLS = 'ls_mainbar_controls'; public const GS_DATA_LS_METABARCONTROLS = 'ls_metabar_controls'; @@ -59,6 +60,10 @@ public function __construct( public function play(RequestWrapper $get): ?string { + $ls_ref_id = $this->ls_items->getLearningSequenceRefId(); + $ls_obj_id = ilObject::_lookupObjId($ls_ref_id); + $ls_title = ilObject::_lookupTitle($ls_obj_id); + $items = $this->ls_items->getItems(); if (count($items) === 0) { @@ -115,6 +120,7 @@ public function play(RequestWrapper $get): ?string //content $obj_title = $next_item->getTitle(); + $obj_description = $next_item->getDescription(); $icon = $this->ui_factory->symbol()->icon()->standard( $next_item->getType(), $next_item->getType(), @@ -141,11 +147,99 @@ public function play(RequestWrapper $get): ?string //amend controls not set by the view $control_builder = $this->buildDefaultControls($control_builder, $item, $item_position, $items); + // Keep a stable header layout: reserve the rating slot even if rating is not available. + // Otherwise the navigation/buttons would shift vertically. + $obj_rating_html = ' '; + $obj_id = ilObject::_lookupObjId($next_item->getRefId()); + if ($obj_id > 0) { + $obj_type = ilObject::_lookupType($obj_id); + ilRating::preloadListGUIData([$obj_id]); + if ($obj_type !== '' && ilRating::hasRatingInListGUI($obj_id, $obj_type)) { + global $DIC; + $lng = $DIC->language(); + + $parent_ref_id = (int) $ls_ref_id; + $rating_container_id = 'lg_div_' . $next_item->getRefId() . '_pref_' . $parent_ref_id; + + $ajax_hash = ilCommonActionDispatcherGUI::buildAjaxHash( + ilCommonActionDispatcherGUI::TYPE_REPOSITORY, + $next_item->getRefId(), + $obj_type, + $obj_id + ); + + $rating_gui = new ilRatingGUI(); + $rating_gui->setObject($obj_id, $obj_type); + $rating_gui->setCtrlPath([ + ilCommonActionDispatcherGUI::class, + ilRatingGUI::class + ]); + $rating_gui->setYourRatingText($lng->txt('rating_your_rating')); + + $rating_content = $rating_gui->getListGUIProperty( + $next_item->getRefId(), + true, + $ajax_hash, + $parent_ref_id + ); + + $tpl = new ilTemplate( + 'tpl.lso_kiosk_rating_container.html', + true, + true, + 'components/ILIAS/LearningSequence' + ); + $tpl->setVariable('CONTAINER_ID', $rating_container_id); + $tpl->setVariable('CHILD_REF_ID', (string) $next_item->getRefId()); + $tpl->setVariable('AJAX_HASH', htmlspecialchars($ajax_hash, ENT_QUOTES)); + $tpl->setVariable('RATING_CONTENT', $rating_content); + $obj_rating_html = $tpl->get(); + + $ilCtrl = $DIC->ctrl(); + $redraw_url = $ilCtrl->getLinkTargetByClass( + ilObjLearningSequenceLearnerGUI::class, + ilObjLearningSequenceLearnerGUI::CMD_REDRAW_LIST_ITEM, + '', + true, + false + ); + + $rating_url = $ilCtrl->getLinkTargetByClass( + [ + ilCommonActionDispatcherGUI::class, + ilRatingGUI::class + ], + 'saveRating', + '', + true, + false + ); + + $this->page_renderer->addOnLoadCode( + "if (window.il && window.il.Object) {" . + " if (typeof window.il.Object.setRedrawListItemUrl === 'function') { window.il.Object.setRedrawListItemUrl(" . json_encode($redraw_url, JSON_THROW_ON_ERROR) . "); }" . + " if (typeof window.il.Object.setRatingUrl === 'function') { window.il.Object.setRatingUrl(" . json_encode($rating_url, JSON_THROW_ON_ERROR) . "); }" . + "}" + ); + + $this->page_renderer->addJs( + 'assets/js/lso_kiosk_rating.js', + true + ); + $this->page_renderer->addOnLoadCode( + "if (window.il && window.il.LSO && window.il.LSO.KioskRating && " . + "typeof window.il.LSO.KioskRating.init === 'function') { window.il.LSO.KioskRating.init(); }" + ); + } + } + $rendered_body = $this->page_renderer->render( $control_builder, $obj_title, + $obj_description, $icon, - $content + $content, + $obj_rating_html ); $metabar_controls = [ @@ -154,7 +248,7 @@ public function play(RequestWrapper $get): ?string $curriculum_slate = $this->page_renderer->buildCurriculumSlate( $this->curriculum_builder - ->getLearnerCurriculum(true) + ->getLearnerCurriculum(true, $ls_title) ->withActive($item_position) ); $mainbar_controls = [ @@ -169,6 +263,7 @@ public function play(RequestWrapper $get): ?string $cc = $this->current_context; $cc->addAdditionalData(self::GS_DATA_LS_KIOSK_MODE, true); + $cc->addAdditionalData(self::GS_DATA_LS_TITLE, $ls_title); $cc->addAdditionalData(self::GS_DATA_LS_METABARCONTROLS, $metabar_controls); $cc->addAdditionalData(self::GS_DATA_LS_MAINBARCONTROLS, $mainbar_controls); $cc->addAdditionalData(self::GS_DATA_LS_CONTENT, $rendered_body); @@ -299,7 +394,12 @@ protected function buildDefaultControls( if (!$control_builder->getNextControl()) { $direction_next = 1; $cmd = ''; - if (!$is_last) { + $param = $direction_next; + + if ($is_last) { + $cmd = self::LSO_CMD_FINISH; + $param = null; + } else { $available = $this->getNextItem($items, $item, $direction_next) ->getAvailability() === Step::AVAILABLE; @@ -309,7 +409,7 @@ protected function buildDefaultControls( } $control_builder = $control_builder - ->next($cmd, $direction_next); + ->next($cmd, $param); } return $control_builder; diff --git a/components/ILIAS/LearningSequence/classes/Player/class.ilObjLearningSequenceLearnerGUI.php b/components/ILIAS/LearningSequence/classes/Player/class.ilObjLearningSequenceLearnerGUI.php index acf49194231e..f37046da5b9f 100755 --- a/components/ILIAS/LearningSequence/classes/Player/class.ilObjLearningSequenceLearnerGUI.php +++ b/components/ILIAS/LearningSequence/classes/Player/class.ilObjLearningSequenceLearnerGUI.php @@ -18,11 +18,20 @@ declare(strict_types=1); +use ILIAS\Filesystem\Stream\Streams; +use ILIAS\HTTP\Response\ResponseHeader; use ILIAS\HTTP\Wrapper\RequestWrapper; use ILIAS\UI\Component\Modal\Interruptive; +/** + * @ilCtrl_Calls ilObjLearningSequenceLearnerGUI: ilCommonActionDispatcherGUI + * @ilCtrl_Calls ilCommonActionDispatcherGUI: ilRatingGUI + */ class ilObjLearningSequenceLearnerGUI { + public const CMD_SAVE_RATING = 'saveRating'; + public const CMD_REDRAW_LIST_ITEM = 'redrawListItem'; + public const CMD_STANDARD = 'learnerView'; public const CMD_EXTRO = 'learnerViewFinished'; public const CMD_UNSUBSCRIBE_CONFIRMATION = 'unsubscribeConfirmation'; @@ -74,6 +83,15 @@ public function executeCommand(): void } $this->ctrl->redirect($this, self::CMD_STANDARD); break; + case self::CMD_SAVE_RATING: + $dispatcher = ilCommonActionDispatcherGUI::getInstanceFromAjaxCall(); + if ($dispatcher instanceof ilCommonActionDispatcherGUI) { + $dispatcher->executeCommand(); + } + break; + case self::CMD_REDRAW_LIST_ITEM: + $this->redrawListItem(); + break; case self::CMD_VIEW: $this->play(); break; @@ -86,6 +104,108 @@ public function executeCommand(): void } } + protected function redrawListItem(): void + { + global $DIC; + + $http = $DIC->http(); + $response = $http->response(); + + $child_ref_id = (int) $this->get->retrieve('child_ref_id', $DIC->refinery()->kindlyTo()->int()); + $parent_ref_id = (int) $this->get->retrieve('parent_ref_id', $DIC->refinery()->kindlyTo()->int()); + + if ($child_ref_id <= 0 || $parent_ref_id <= 0) { + $http->saveResponse( + $response + ->withBody(Streams::ofString('')) + ->withHeader(ResponseHeader::CONTENT_TYPE, 'text/html; charset=UTF-8') + ); + $http->sendResponse(); + $http->close(); + return; + } + + $obj_id = ilObject::_lookupObjId($child_ref_id); + if ($obj_id <= 0) { + $http->saveResponse( + $response + ->withBody(Streams::ofString('')) + ->withHeader(ResponseHeader::CONTENT_TYPE, 'text/html; charset=UTF-8') + ); + $http->sendResponse(); + $http->close(); + return; + } + + $obj_type = ilObject::_lookupType($obj_id); + if ($obj_type === '') { + $http->saveResponse( + $response + ->withBody(Streams::ofString('')) + ->withHeader(ResponseHeader::CONTENT_TYPE, 'text/html; charset=UTF-8') + ); + $http->sendResponse(); + $http->close(); + return; + } + + ilRating::preloadListGUIData([$obj_id]); + if (!ilRating::hasRatingInListGUI($obj_id, $obj_type)) { + $http->saveResponse( + $response + ->withBody(Streams::ofString('')) + ->withHeader(ResponseHeader::CONTENT_TYPE, 'text/html; charset=UTF-8') + ); + $http->sendResponse(); + $http->close(); + return; + } + + $ajax_hash = ilCommonActionDispatcherGUI::buildAjaxHash( + ilCommonActionDispatcherGUI::TYPE_REPOSITORY, + $child_ref_id, + $obj_type, + $obj_id + ); + + $rating_gui = new ilRatingGUI(); + $rating_gui->setObject($obj_id, $obj_type); + $rating_gui->setCtrlPath([ + ilCommonActionDispatcherGUI::class, + ilRatingGUI::class + ]); + $rating_gui->setYourRatingText($this->lng->txt('rating_your_rating')); + + $container_id = 'lg_div_' . $child_ref_id . '_pref_' . $parent_ref_id; + $rating_content = $rating_gui->getListGUIProperty( + $child_ref_id, + true, + $ajax_hash, + $parent_ref_id + ); + + $tpl = new ilTemplate( + 'tpl.lso_kiosk_rating_container.html', + true, + true, + 'components/ILIAS/LearningSequence' + ); + $tpl->setVariable('CONTAINER_ID', $container_id); + $tpl->setVariable('CHILD_REF_ID', (string) $child_ref_id); + $tpl->setVariable('AJAX_HASH', htmlspecialchars($ajax_hash, ENT_QUOTES)); + $tpl->setVariable('RATING_CONTENT', $rating_content); + $html = $tpl->get(); + + $http->saveResponse( + $response + ->withBody(Streams::ofString($html)) + ->withHeader(ResponseHeader::CONTENT_TYPE, 'text/html; charset=UTF-8') + ); + $http->sendResponse(); + $http->close(); + return; + } + protected function unsubscribeConfirmationModal(): Interruptive { return $this->ui_factory->modal()->interruptive( diff --git a/components/ILIAS/LearningSequence/resources/js/lso_kiosk_rating.js b/components/ILIAS/LearningSequence/resources/js/lso_kiosk_rating.js new file mode 100644 index 000000000000..eb80f78fd715 --- /dev/null +++ b/components/ILIAS/LearningSequence/resources/js/lso_kiosk_rating.js @@ -0,0 +1,217 @@ +/** + * 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 (window) { + if (!window.il) { + return; + } + + window.il.LSO = window.il.LSO || {}; + + const STYLE_ID = 'il-lso-kiosk-rating-popover-style'; + + function ensurePopoverNoWrapStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + + const style = document.createElement('style'); + style.id = STYLE_ID; + style.type = 'text/css'; + style.appendChild( + document.createTextNode( + '.webui-popover .ilRatingOverlay ul.il-rating-stars{' + + 'display:inline-flex !important;' + + 'flex-wrap:nowrap !important;' + + 'white-space:nowrap !important;' + + 'padding:0 !important;' + + 'margin:0 !important;' + + 'list-style:none !important;' + + '}' + + '.webui-popover .ilRatingOverlay ul.il-rating-stars>li{flex:0 0 auto !important;}' + + '.webui-popover .ilRatingOverlay ul.il-rating-stars button.btn{padding:0 !important;}' + + '.webui-popover .ilRatingOverlay ul.il-rating-stars img{max-width:none !important;}', + ), + ); + document.head.appendChild(style); + } + ensurePopoverNoWrapStyles(); + + /** + * Rating support for the LSO kiosk player. + * + * In the kiosk layout, rating popover wiring (webuiPopover) is not reliably + * executed after the rating snippet is replaced via AJAX. Therefore we bind + * popover opening and overlay-star clicks via delegated handlers. + */ + window.il.LSO.KioskRating = window.il.LSO.KioskRating || (function () { + let initialized = false; + + function hasJQuery() { + return typeof window.$ !== 'undefined'; + } + + function bindOverlayStars() { + window.$(document).on('click', '.ilRatingOverlay .il-rating-stars button', function (e) { + const $btn = window.$(this); + const popoverId = ($btn.closest('.webui-popover').attr('id') || ''); + if (!popoverId) { + return; + } + + const $trigger = window.$(`[data-target='${popoverId}']`).first(); + if (!$trigger.length) { + return; + } + + const $lg = $trigger.closest('div[id^=lg_div_]'); + if (!$lg.length) { + return; + } + + const refId = parseInt($lg.attr('data-lso-rating-refid') || '', 10); + const hash = ($lg.attr('data-lso-rating-hash') || ''); + if (!refId || !hash) { + return; + } + + const rating = ($btn.closest('li').index() || 0) + 1; + + e.preventDefault(); + e.stopPropagation(); + if (window.il && window.il.Object && typeof window.il.Object.saveRatingFromListGUI === 'function') { + window.il.Object.saveRatingFromListGUI(refId, hash, rating); + } + }); + } + + function bindTriggerPopover() { + window.$(document).on( + 'click', + '.ilLSOKioskModeObjectHeader a.ilRating, .ilLSOKioskModeObjectHeader button.ilRating', + function (e) { + const $t = window.$(this); + const $lg = $t.closest('div[id^=lg_div_]'); + if (!$lg.length) { + return; + } + const $content = $lg.find('.il-standard-popover-content').first(); + if (!$content.length) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + try { + ensurePopoverNoWrapStyles(); + + try { + const dt = $t.attr('data-target'); + $t.webuiPopover('destroy'); + $t.removeData('plugin_webuiPopover'); + $t.removeAttr('data-target'); + if (dt) { + const selector = dt.startsWith('#') ? dt : `#${dt}`; + window.$(selector).remove(); + } + } catch (e2) { + // ignore + } + + $t.webuiPopover({ + trigger: 'click', + placement: 'auto', + // Let the plugin compute width. + width: 'auto', + multi: true, + container: window.$('body'), + closeable: false, + content() { + return $content.html(); + }, + }); + $t.webuiPopover('show'); + } catch (ex) { + // ignore + } + }, + ); + } + + function overrideSaveRatingFromListGUI() { + if (!window.il || !window.il.Object || typeof window.il.Object.saveRatingFromListGUI !== 'function') { + return; + } + + const original = window.il.Object.saveRatingFromListGUI; + + window.il.Object.saveRatingFromListGUI = function (refId, hash, mark) { + const urlRedraw = window.il.Object.url_redraw_li; + + window.$.ajax({ + url: `${window.il.Object.url_rating}&child_ref_id=${refId}&cadh=${encodeURIComponent(hash)}`, + data: { rating: mark }, + type: 'POST', + success() { + if (window.WebuiPopovers && typeof window.WebuiPopovers.hideAll === 'function') { + window.WebuiPopovers.hideAll(); + } + + window.$(`div[id^=lg_div_${refId}_pref_]`).each(function () { + const id = window.$(this).attr('id'); + const parent = id.split('_').pop(); + + if (urlRedraw) { + window.$.ajax({ + url: `${urlRedraw}&child_ref_id=${refId}&parent_ref_id=${parent}`, + type: 'GET', + success(html) { + window.$(`#${id}`).replaceWith(html); + }, + }); + } else if (typeof original === 'function') { + original(refId, hash, mark); + } + }); + }, + }); + }; + } + + function init() { + if (initialized) { + return; + } + initialized = true; + + if (!hasJQuery()) { + return; + } + if (typeof window.$.fn.webuiPopover !== 'function') { + return; + } + + ensurePopoverNoWrapStyles(); + bindOverlayStars(); + bindTriggerPopover(); + overrideSaveRatingFromListGUI(); + } + + return { + init, + }; + }()); +}(window)); diff --git a/components/ILIAS/LearningSequence/templates/default/tpl.kioskpage.html b/components/ILIAS/LearningSequence/templates/default/tpl.kioskpage.html index b6acc4314309..cf5ea961ec8f 100755 --- a/components/ILIAS/LearningSequence/templates/default/tpl.kioskpage.html +++ b/components/ILIAS/LearningSequence/templates/default/tpl.kioskpage.html @@ -2,12 +2,21 @@