From e14be93628294f72083a37cb428cb16d4f1ed41a Mon Sep 17 00:00:00 2001 From: Thibeau Fuhrer Date: Tue, 4 Aug 2026 13:55:11 +0200 Subject: [PATCH 1/2] [FEATURE] BackgroundTask: replace legacy progress bar (attempt). --- components/ILIAS/BackgroundTasks/README.md | 27 +++- .../BackgroundTasks_/BackgroundTasks_.php | 2 - .../Provider/BTNotificationProvider.php | 1 - .../classes/class.ilBTControllerGUI.php | 54 +++++-- .../classes/class.ilBTPopOverGUI.php | 133 +++++++++++------- .../resources/background_task_refresh.js | 26 ---- lang/ilias_de.lang | 3 +- lang/ilias_en.lang | 3 +- 8 files changed, 152 insertions(+), 97 deletions(-) delete mode 100644 components/ILIAS/BackgroundTasks_/resources/background_task_refresh.js diff --git a/components/ILIAS/BackgroundTasks/README.md b/components/ILIAS/BackgroundTasks/README.md index ffc69bfc8997..e047acd3c9e0 100755 --- a/components/ILIAS/BackgroundTasks/README.md +++ b/components/ILIAS/BackgroundTasks/README.md @@ -97,4 +97,29 @@ $observer = new MockObserver(); $value = $taskManager->executeTask($task, $observer); echo $value->getValue(); // will echo 2. ``` - + +Configuring the component +------------------------- + +To configure this component you need to add this to your `ilias.ini.php` file: + +```ini +[background_tasks] +number_of_concurrent_tasks = +concurrency = +``` + +* Replace `` with a positive integer that defines how many tasks can run in parallel (only for `async` concurrency). +* Replace `` with one of these options: + * `sync`: perform tasks synchronously in the same request + * `async`: perform tasks asynchronously (needs SOAP to be elabled) + +Pro-tip for developers +---------------------- + +Run this database update to enable SOAP quickly: + +```sql +update settings set value = 1 where keyword = 'soap_user_administration'; +update settings set value = "http://localhost/soap/server.php?wsdl" where keyword = 'soap_internal_wsdl_path'; +``` diff --git a/components/ILIAS/BackgroundTasks_/BackgroundTasks_.php b/components/ILIAS/BackgroundTasks_/BackgroundTasks_.php index 150aa951659a..97ff2c041cbf 100644 --- a/components/ILIAS/BackgroundTasks_/BackgroundTasks_.php +++ b/components/ILIAS/BackgroundTasks_/BackgroundTasks_.php @@ -36,7 +36,5 @@ public function init( array | \ArrayAccess &$pull, array | \ArrayAccess &$internal, ): void { - $contribute[PublicAsset::class] = fn(): ComponentJS => - new ComponentJS($this, "background_task_refresh.js"); } } diff --git a/components/ILIAS/BackgroundTasks_/classes/Provider/BTNotificationProvider.php b/components/ILIAS/BackgroundTasks_/classes/Provider/BTNotificationProvider.php index 75f9124674a1..009ad4f329b4 100755 --- a/components/ILIAS/BackgroundTasks_/classes/Provider/BTNotificationProvider.php +++ b/components/ILIAS/BackgroundTasks_/classes/Provider/BTNotificationProvider.php @@ -40,7 +40,6 @@ public function getNotifications(): array return []; } - $this->dic->ui()->mainTemplate()->addJavaScript("assets/js/background_task_refresh.js"); $this->dic->language()->loadLanguageModule('background_tasks'); $id = fn(string $id): IdentificationInterface => $this->if->identifier($id); diff --git a/components/ILIAS/BackgroundTasks_/classes/class.ilBTControllerGUI.php b/components/ILIAS/BackgroundTasks_/classes/class.ilBTControllerGUI.php index f6e9d13d048c..6b768e7b734b 100755 --- a/components/ILIAS/BackgroundTasks_/classes/class.ilBTControllerGUI.php +++ b/components/ILIAS/BackgroundTasks_/classes/class.ilBTControllerGUI.php @@ -42,14 +42,18 @@ class ilBTControllerGUI implements ilCtrlBaseClassInterface, ilCtrlSecurityInter public const CMD_REMOVE = 'abortBucket'; public const CMD_USER_INTERACTION = 'userInteraction'; public const IS_ASYNC = 'bt_task_is_async'; - public const CMD_GET_REPLACEMENT_ITEM = "getAsyncReplacementItem"; + public const CMD_REFRESH_NOTIFICATION_ITEM = "getAsyncNotificationItemState"; + public const CMD_PROGRESS_BAR_STATE = "getAsyncProgressBarState"; public function executeCommand(): void { $cmd = $this->ctrl()->getCmd(); switch ($cmd) { - case self::CMD_GET_REPLACEMENT_ITEM: - $this->getAsyncReplacementItem(); + case self::CMD_REFRESH_NOTIFICATION_ITEM: + $this->getAsyncNotificationItemReplacement(); + break; + case self::CMD_PROGRESS_BAR_STATE: + $this->getAsyncProgressBarState(); break; case self::CMD_USER_INTERACTION: $this->userInteraction(); @@ -116,10 +120,10 @@ protected function abortBucket(): void /** - * Loads one single aggregate notification item representing a button async - * to replace an existing one. + * Updates the @see ILIAS\UI\Component\Progress\Bar of the requested observer (id) + * on the client asynchronously. */ - protected function getAsyncReplacementItem(): void + protected function getAsyncProgressBarState(): void { $observer_id = $this->retrieveObserverIdFromRequest(); if ($observer_id === null) { @@ -132,13 +136,41 @@ protected function getAsyncReplacementItem(): void $item_source = new ilBTPopOverGUI($this->dic()); $this->dic()->language()->loadLanguageModule('background_tasks'); - $item = $item_source->getItemForObserver($bucket); - $this->sendSuccessResponse( - Streams::ofString( - $this->dic()->ui()->renderer()->renderAsync($item) - ) + $progress_bar_state = $item_source->getProgressBarState($bucket); + $html = $this->ui()->renderer()->renderAsync($progress_bar_state); + + $this->sendHtmlResponse($html); + } + + /** + * Updates the @see ILIAS\UI\Component\Item\Notification SURROUNDINGS (not content) + * of the requested observer (id) on the client asynchronously. + */ + protected function getAsyncNotificationItemReplacement(): void + { + $observer_id = (int) $this->http()->request()->getQueryParams()[self::OBSERVER_ID]; + $bucket = $this->dic()->backgroundTasks()->persistence()->loadBucket($observer_id); + + $item_source = new ilBTPopOverGUI($this->dic()); + $this->dic()->language()->loadLanguageModule('background_tasks'); + + $replacement_notification_item = $item_source->getItemForObserver($bucket); + $html = $this->ui()->renderer()->renderAsync($replacement_notification_item); + + $this->sendHtmlResponse($html); + } + + + protected function sendHtmlResponse(string $html): void + { + $this->http()->saveResponse( + $this->http()->response() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody(Streams::ofString($html)) ); + $this->http()->sendResponse(); + $this->http()->close(); } diff --git a/components/ILIAS/BackgroundTasks_/classes/class.ilBTPopOverGUI.php b/components/ILIAS/BackgroundTasks_/classes/class.ilBTPopOverGUI.php index 8577b6f034b3..9cda4a8d7476 100755 --- a/components/ILIAS/BackgroundTasks_/classes/class.ilBTPopOverGUI.php +++ b/components/ILIAS/BackgroundTasks_/classes/class.ilBTPopOverGUI.php @@ -28,6 +28,12 @@ use ILIAS\UI\Component\Button\Shy; use ILIAS\UI\Component\Legacy\Content; use ILIAS\BackgroundTasks\Task\Job; +use ILIAS\Filesystem\Stream\Streams; +use ILIAS\UI\Component\Progress\Bar; +use ILIAS\UI\Component\Component; +use ILIAS\BackgroundTasks\Task; +use ILIAS\UI\Implementation\Component\Signal; +use ILIAS\UI\Implementation\Component\SignalGenerator; /** * Class ilBTPopOverGUI @@ -39,12 +45,14 @@ class ilBTPopOverGUI { use StateTranslator; + protected ILIAS\Data\Factory $data_factory; public function __construct(protected Container $dic) { + // this is bad, we should inject this. + $this->data_factory = new ILIAS\Data\Factory(); } - /** * Get the Notification Items. DOES NOT DO ANY PERMISSION CHECKS. */ @@ -88,6 +96,8 @@ public function getItemForObserver(Bucket $observer): Notification $state = $observer->getState(); $current_task = $observer->getCurrentTask(); + $progress_bar = $this->getProgressbar($observer); + $icon = $f->symbol()->icon()->standard("bgtk", $this->txt("bg_task")); $title = $observer->getTitle() . ($state === State::SCHEDULED ? " ({$this->txt('scheduled')})" : ""); @@ -98,23 +108,16 @@ public function getItemForObserver(Bucket $observer): Notification $title = $primary_action->withLabel($title); } $item = $f->item()->notification($title, $icon); - - // $item = $item->withProperties([ - // $this->dic->language()->txt('nc_mail_prop_time') => \ilDatePresentation::formatDate( - // new \ilDateTime(time(), IL_CAL_UNIX) - // ) - // ]); - $item = $item->withActions($f->dropdown()->standard($actions)); $input = $current_task->getInput(); $message = $current_task->getMessage($input); - if (!empty($message) && $message != null) { + if (!empty($message)) { $item = $item->withDescription($message); - } else { - $item = $item->withAdditionalContent($this->getProgressbar($observer)); } + $item = $item->withAdditionalContent($this->getUIComponentAsLegacyContent($progress_bar)); + return $item->withCloseAction( $this->getCloseButtonAction($current_task->getRemoveOption(), $redirect_uri, $observer) ); @@ -122,29 +125,17 @@ public function getItemForObserver(Bucket $observer): Notification $item = $f->item()->notification($title, $icon); - if ($state === State::RUNNING) { - $url = $this->getRefreshUrl($observer); - //Running Items probably need to refresh themselves, right? - $item = $item->withAdditionalOnLoadCode(fn($id): string => "var notification_item = il.UI.item.notification.getNotificationItemObject($('#$id')); - il.BGTask.refreshItem(notification_item,'$url');"); - - $expected = $current_task instanceof Job ? $current_task->getExpectedTimeOfTaskInSeconds() : 0; - $possibly_failed = ($observer->getLastHeartbeat() < (time() - $expected)); - if ($possibly_failed) { - $item = $item->withDescription($this->txt('task_might_be_failed')); - $item = $item->withCloseAction( - $this->getCloseButtonAction($current_task->getAbortOption(), $redirect_uri, $observer) - ); - } + if ($state === State::RUNNING && $this->hasBucketPossiblyFailed($observer)) { + $item = $item->withCloseAction( + $this->getCloseButtonAction($current_task->getAbortOption(), $redirect_uri, $observer) + ); } - return $item->withAdditionalContent($this->getDefaultCardContent($observer)); - } - + $item = $item->withCloseAction( + $this->getCloseButtonAction($current_task->getAbortOption(), $redirect_uri, $observer) + ); - private function getDefaultCardContent(Bucket $observer): Content - { - return $this->getProgressbar($observer); + return $item->withAdditionalContent($this->getUIComponentAsLegacyContent($progress_bar)); } @@ -194,32 +185,40 @@ function (Option $option) use ($ctrl, $factory, $observer, $persistence, $redire ); } + private function getProgressbar(Bucket $observer): Bar + { + $progress_bar = $this->dic->ui()->factory()->progress()->bar( + $this->txt('progress'), + $this->data_factory->uri(ILIAS_HTTP_PATH . '/' . $this->getProgressStateUrl($observer)) + ); + + // immediately start the progress bar after being rendered + $progress_bar = $progress_bar->withAdditionalOnLoadCode(fn($id) => " + il.UI.Progress.Bar.indeterminate( + '{$progress_bar->getUpdateSignal()}', + '{$this->txt('scheduled')}', + ); + "); + + return $progress_bar; + } - private function getProgressbar(Bucket $observer): Content + public function getProgressBarState(Bucket $observer): \ILIAS\UI\Component\Progress\State\Bar\State { - $percentage = $observer->getOverallPercentage(); + $task_not_responding_state = $this->dic->ui()->factory()->progress()->state()->bar()->failure($this->txt('task_might_be_failed')); - switch (true) { - case ($percentage === 100): - $running = ""; - $content = $this->dic->language()->txt("completed"); - break; - case ($observer->getState() === State::USER_INTERACTION): - $running = ""; - $content = $this->dic->language()->txt("waiting"); - break; - default: - $running = "active"; - $content = "{$percentage}%"; - break; + if ($this->hasBucketPossiblyFailed($observer)) { + return $task_not_responding_state; } - return $this->dic->ui()->factory()->legacy()->content("
-
- {$content} -
-
"); + $percentage = $observer->getOverallPercentage(); + if (100 > $percentage) { + return $this->dic->ui()->factory()->progress()->state()->bar()->determinate($percentage, $this->txt('waiting')); + } + if (100 <= $percentage) { + return $this->dic->ui()->factory()->progress()->state()->bar()->success($this->txt('completed')); + } + return $task_not_responding_state; } @@ -247,14 +246,40 @@ private function getCloseButtonAction(Option $option, string $redirect_uri, Buck return $action; } + protected function getUIComponentAsLegacyContent(Component $component): Content + { + return $this->dic->ui()->factory()->legacy()->content( + $this->dic->ui()->renderer()->render($component), + ); + } - private function getRefreshUrl(Bucket $observer): string + protected function hasBucketPossiblyFailed(Bucket $observer): bool + { + $task = $observer->getCurrentTask(); + $expected = $task instanceof Job ? $task->getExpectedTimeOfTaskInSeconds() : 0; + return ($observer->getLastHeartbeat() < (time() - $expected)); + } + + private function getProgressStateUrl(Bucket $observer): string { $ctrl = $this->dic->ctrl(); $persistence = $this->dic->backgroundTasks()->persistence(); $ctrl->setParameterByClass(ilBTControllerGUI::class, ilBTControllerGUI::OBSERVER_ID, $persistence->getBucketContainerId($observer)); - return $ctrl->getLinkTargetByClass([ilBTControllerGUI::class], ilBTControllerGUI::CMD_GET_REPLACEMENT_ITEM); + return $ctrl->getLinkTargetByClass([ilBTControllerGUI::class], ilBTControllerGUI::CMD_PROGRESS_BAR_STATE); + } + + private function getRefreshNotificationItemUrl(Bucket $observer): string + { + $ctrl = $this->dic->ctrl(); + $persistence = $this->dic->backgroundTasks()->persistence(); + $ctrl->setParameterByClass( + ilBTControllerGUI::class, + ilBTControllerGUI::OBSERVER_ID, + $persistence->getBucketContainerId($observer) + ); + + return $ctrl->getLinkTargetByClass([ilBTControllerGUI::class], ilBTControllerGUI::CMD_REFRESH_NOTIFICATION_ITEM); } diff --git a/components/ILIAS/BackgroundTasks_/resources/background_task_refresh.js b/components/ILIAS/BackgroundTasks_/resources/background_task_refresh.js deleted file mode 100644 index f7a41959b735..000000000000 --- a/components/ILIAS/BackgroundTasks_/resources/background_task_refresh.js +++ /dev/null @@ -1,26 +0,0 @@ -il = il || {}; -il.BGTask = il.BGTask || {}; -(function ($, il) { - il.BGTask = (function ($) { - var refreshments = 0; - - var refreshItem = function (notification_item, url) { - setTimeout(function () { - console.log("Item has been refreshed: " + refreshments++); - //@TODO, when do we need to replace content? - if (true) { - notification_item.replaceByAsyncItem(url, {refreshes: refreshments}); - } - // do some stuff - }, 2000); - }; - - return { - refreshItem: refreshItem - }; - })($); -})($, il); - - - - diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index fd86fa208a60..d549f2c1d08a 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -2206,6 +2206,7 @@ background_tasks#:#background_tasks#:#Background Tasks background_tasks#:#background_tasks_running#:#Background Tasks background_tasks#:#completed#:#Abgeschlossen background_tasks#:#proceed_job#:#Weiter +background_tasks#:#progress#:#Fortschritt der Aufgabe background_tasks#:#scheduled#:#Geplant background_tasks#:#task_might_be_failed#:#Diese Aufgabe hat seit einiger Zeit nicht geantwortet, Sie können sie beenden, falls nötig. background_tasks#:#ui_msg_files_violate_maxsize#:#Die Gesamtdownloadgröße überschreitet das globale Limit. Der Download wird abgebrochen. @@ -18148,4 +18149,4 @@ wsp#:#wsp_type_ltiv#:#Zertifikat: LTI-Konsument wsp#:#wsp_type_scov#:#Zertifikat: SCORM wsp#:#wsp_type_tstv#:#Zertifikat: Test wsp#:#wsp_type_webr#:#Weblink -wsp#:#wsp_type_wfld#:#Ordner \ No newline at end of file +wsp#:#wsp_type_wfld#:#Ordner diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 5eb2bbca8e96..2ad27f42b4b7 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -2206,6 +2206,7 @@ background_tasks#:#background_tasks#:#Background Tasks background_tasks#:#background_tasks_running#:#Background Tasks background_tasks#:#completed#:#Completed background_tasks#:#proceed_job#:#Proceed +background_tasks#:#progress#:#Progress of task background_tasks#:#scheduled#:#Scheduled background_tasks#:#task_might_be_failed#:#This Task has not responded for a while, you can quit it if needed. background_tasks#:#ui_msg_files_violate_maxsize#:#The total download size violates the global limit. The download will be aborted. @@ -18097,4 +18098,4 @@ wsp#:#wsp_type_ltiv#:#Certificate: LTI Consumer wsp#:#wsp_type_scov#:#Certificate: SCORM wsp#:#wsp_type_tstv#:#Certificate: Test wsp#:#wsp_type_webr#:#Weblink -wsp#:#wsp_type_wfld#:#Folder \ No newline at end of file +wsp#:#wsp_type_wfld#:#Folder From 83466ea37503c35922f8cd4ea9e2cc93e8f443fc Mon Sep 17 00:00:00 2001 From: Thibeau Fuhrer Date: Tue, 4 Aug 2026 14:23:08 +0200 Subject: [PATCH 2/2] [FEATURE] UI: possible progress bar update listener --- .../js/Progress/dist/progress.min.js | 2 +- .../UI/resources/js/Progress/rollup.config.js | 17 +++--- .../resources/js/Progress/src/ProgressBar.js | 44 +++++++++++++++ .../Progress/src/ProgressBarAsyncDecorator.js | 8 +++ .../js/Progress/src/ProgressUpdateEvent.js | 53 +++++++++++++++++++ .../UI/resources/js/Progress/src/facade.js | 12 ++++- .../Component/Progress/State/Bar/Status.php | 43 +++++++++++++++ .../UI/src/examples/Progress/Bar/server.php | 3 ++ 8 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 components/ILIAS/UI/resources/js/Progress/src/ProgressUpdateEvent.js create mode 100644 components/ILIAS/UI/src/Component/Progress/State/Bar/Status.php diff --git a/components/ILIAS/UI/resources/js/Progress/dist/progress.min.js b/components/ILIAS/UI/resources/js/Progress/dist/progress.min.js index c277d4a50016..b794d6d83ed4 100644 --- a/components/ILIAS/UI/resources/js/Progress/dist/progress.min.js +++ b/components/ILIAS/UI/resources/js/Progress/dist/progress.min.js @@ -12,4 +12,4 @@ * https://www.ilias.de * https://github.com/ILIAS-eLearning */ -!function(e,t){"use strict";class s{#e;#t;constructor(e,t){this.#e=e,this.#t=t}indeterminate(e=null){this.#e.hasAttribute("value")&&this.#e.removeAttribute("value"),null!==e&&this.#s(e)}determinate(e,t=null){if(!Number.isInteger(e)||e<0||e>=this.#e.max)throw new Error(`Progress value must be a whole number between 0 and ${this.#e.max}.`);this.#e.value=e,null!==t&&this.#s(t)}success(e){this.#e.value!==this.#e.max&&this.#r(e,"success")}failure(e){this.#e.value!==this.#e.max&&this.#r(e,"failure")}reset(){this.#e.labels.forEach((e=>{e.querySelectorAll("span[data-status]").forEach((e=>{e.classList.remove("visible"),e.classList.add("hidden")}))})),this.#e.classList.remove("c-progress-bar--success"),this.#e.classList.remove("c-progress-bar--failure"),this.#e.value=0,this.#n()}#r(e,t){this.#e.labels.forEach((e=>{e.parentElement.querySelectorAll("span[data-status]").forEach((e=>{e.getAttribute("data-status")===t?(e.classList.remove("hidden"),e.classList.add("visible")):(e.classList.remove("visible"),e.classList.add("hidden"))}))})),this.#e.value=this.#e.max,this.#e.classList.add(`c-progress-bar--${t}`),this.#s(e)}#s(e){this.#t.classList.remove("invisible"),this.#t.classList.add("visible"),this.#t.textContent=e}#n(){this.#t.classList.remove("visible"),this.#t.classList.add("invisible"),this.#t.textContent=""}}function r(e,t){if(!(t instanceof e.defaultView.HTMLProgressElement))throw new Error("Progress bar must have a element.");const r=t.parentElement.querySelector(".c-progress-bar__message");if(null===r)throw new Error("Could not find progress bar message element.");return new s(t,r)}class n{#i;#a;#l;#o;#c=null;#h=!1;constructor(e,t,s,r){this.#i=e,this.#a=t,this.#l=s,this.#o=r}#u(e){const t=function(e){const t=e.querySelector('section[data-section="status"]');return null!==t&&t.hasAttribute("data-status")?t.getAttribute("data-status"):null}(e),s=function(e){const t=e.querySelector('section[data-section="progress"] > progress');return t instanceof e.ownerDocument.defaultView.HTMLProgressElement?parseInt(t.value,10):null}(e),r=function(e){const t=e.querySelector('section[data-section="message"]');return null!==t&&t.innerText?t.innerText:null}(e);"determinate"===t?this.determinate(s??-1,r):"indeterminate"===t?this.indeterminate(r):"success"===t?this.success(r):this.failure(r)}#m(){null===this.#c&&(this.#c=setInterval((()=>this.#g()),this.#l))}#d(){null!==this.#c&&(clearInterval(this.#c),this.#c=null)}async#g(){if(!this.#h)try{this.#h=!0;const e=await this.#i.loadContent(this.#o);this.#u(e)}catch(e){this.failure(e.message)}finally{this.#h=!1}}indeterminate(e=null){this.#a.indeterminate(e),this.#m()}determinate(e,t=null){this.#a.determinate(e,t)}success(e){this.#a.success(e),this.#d()}failure(e){this.#a.failure(e),this.#d()}reset(){this.#a.reset(),this.#d()}}const i=new class{#p;constructor(e){this.#p=e}loadContent(e){return fetch(e.toString()).then((e=>e.text())).then((e=>this.#f(e))).then((e=>this.#E(e))).catch((t=>{throw new Error(`Could not render element(s) from '${e}': ${t.message}`)}))}#b(e){const t=this.#p.createElement("script");return e.hasAttribute("type")&&t.setAttribute("type",e.getAttribute("type")),e.hasAttribute("src")&&t.setAttribute("src",e.getAttribute("src")),e.textContent.length>0&&(t.textContent=e.textContent),t}#f(e){const t=this.#p.createElement("div");return t.innerHTML=e.trim(),t.querySelectorAll("script").forEach((e=>{const t=this.#b(e);e.replaceWith(t)})),t.children}#E(e){const t=this.#p.createDocumentFragment();return t.append(...e),t}}(t),a=new class{#v=new Map;register(e,t){this.#v.has(t)||this.#v.set(t,e)}indeterminate(e,t=null){this.#w(e).indeterminate(t)}determinate(e,t,s=null){this.#w(e).determinate(t,s)}success(e,t){this.#w(e).success(t)}failure(e,t){this.#w(e).failure(t)}#w(e){if(!this.#v.has(e))throw new Error(`Could not find progress bar component for signal '${e}'`);return this.#v.get(e)}};e.UI=e.UI||{},e.UI.Progress={},e.UI.Progress.Bar={indeterminate:(e,t)=>a.indeterminate(e,t),success:(e,t)=>a.success(e,t),failure:(e,t)=>a.failure(e,t),determinate:(e,t,s)=>a.determinate(e,t,s),createAsync:(e,s,l)=>{const o=function(e,t,s,r){if(!s.hasAttribute("data-url"))throw new Error('Async progress bar must provide a "data-url" attribute.');return new n(e,t,r,s.getAttribute("data-url"))}(i,r(t,e),e,l);return a.register(o,s),o},create:(e,s)=>{const n=r(t,e);return a.register(n,s),n}}}(il,document); +!function(e,t){"use strict";class s{#e;constructor(e,t,s){this.#e=e,this.value=t,this.message=s}isIndeterminate(){return"indeterminate"===this.#e}isDeterminate(){return"determinate"===this.#e}isSuccess(){return"success"===this.#e}isFailure(){return"failure"===this.#e}}class r{#t;#s;#r=new Set;constructor(e,t){this.#t=e,this.#s=t}removeUpdateListener(e){this.#r.has(e)&&this.#r.delete(e)}addUpdateListener(e){this.#r.has(e)||this.#r.add(e)}indeterminate(e=null){this.#t.hasAttribute("value")&&this.#t.removeAttribute("value"),null!==e?this.#i(e):this.#n(),this.#a("indeterminate")}determinate(e,t=null){if(!Number.isInteger(e)||e<0||e>=this.#t.max)throw new Error(`Progress value must be a whole number between 0 and ${this.#t.max}.`);this.#t.value=e,null!==t?this.#i(t):this.#n(),this.#a("determinate")}success(e){this.#t.value!==this.#t.max&&this.#o(e,"success"),this.#a("success")}failure(e){this.#t.value!==this.#t.max&&this.#o(e,"failure"),this.#a("failure")}reset(){this.#t.labels.forEach(e=>{e.querySelectorAll("span[data-status]").forEach(e=>{e.classList.remove("visible"),e.classList.add("hidden")})}),this.#t.classList.remove("c-progress-bar--success"),this.#t.classList.remove("c-progress-bar--failure"),this.#t.value=0,this.#n()}#o(e,t){this.#t.labels.forEach(e=>{e.parentElement.querySelectorAll("span[data-status]").forEach(e=>{e.getAttribute("data-status")===t?(e.classList.remove("hidden"),e.classList.add("visible")):(e.classList.remove("visible"),e.classList.add("hidden"))})}),this.#t.value=this.#t.max,this.#t.classList.add(`c-progress-bar--${t}`),this.#i(e)}#a(e){const t=new s(e,this.#t.value,this.#s.textContent);this.#r.forEach(e=>e(t))}#i(e){this.#s.classList.remove("invisible"),this.#s.classList.add("visible"),this.#s.textContent=e}#n(){this.#s.classList.remove("visible"),this.#s.classList.add("invisible"),this.#s.textContent=""}}function i(e,t){if(!(t instanceof e.defaultView.HTMLProgressElement))throw new Error("Progress bar must have a element.");const s=t.parentElement.querySelector(".c-progress-bar__message");if(null===s)throw new Error("Could not find progress bar message element.");return new r(t,s)}class n{#l;#h;#c;#u;#d=null;#m=!1;constructor(e,t,s,r){this.#l=e,this.#h=t,this.#c=s,this.#u=r}#g(e){const t=function(e){const t=e.querySelector('section[data-section="status"]');return null!==t&&t.hasAttribute("data-status")?t.getAttribute("data-status"):null}(e),s=function(e){const t=e.querySelector('section[data-section="progress"] > progress');return t instanceof e.ownerDocument.defaultView.HTMLProgressElement?parseInt(t.value,10):null}(e),r=function(e){const t=e.querySelector('section[data-section="message"]');return null!==t&&t.innerText?t.innerText:null}(e);"determinate"===t?this.determinate(s??-1,r):"indeterminate"===t?this.indeterminate(r):"success"===t?this.success(r):this.failure(r)}#p(){null===this.#d&&(this.#d=setInterval(()=>this.#f(),this.#c))}#E(){null!==this.#d&&(clearInterval(this.#d),this.#d=null)}async#f(){if(!this.#m)try{this.#m=!0;const e=await this.#l.loadContent(this.#u);this.#g(e)}catch(e){this.failure(e.message)}finally{this.#m=!1}}indeterminate(e=null){this.#h.indeterminate(e),this.#p()}determinate(e,t=null){this.#h.determinate(e,t)}success(e){this.#h.success(e),this.#E()}failure(e){this.#h.failure(e),this.#E()}reset(){this.#h.reset(),this.#E()}}const a=new class{#b;constructor(e){this.#b=e}loadContent(e){return fetch(e.toString()).then(e=>e.text()).then(e=>this.#v(e)).then(e=>function(e,t){const s=e.createDocumentFragment();return s.append(...t),s}(this.#b,e)).catch(t=>{throw new Error(`Could not render element(s) from '${e}': ${t.message}`)})}#w(e){const t=this.#b.createElement("script");return e.hasAttribute("type")&&t.setAttribute("type",e.getAttribute("type")),e.hasAttribute("src")&&t.setAttribute("src",e.getAttribute("src")),e.textContent.length>0&&(t.textContent=e.textContent),t}#v(e){const t=this.#b.createElement("div");return t.innerHTML=e.trim(),t.querySelectorAll("script").forEach(e=>{const t=this.#w(e);e.replaceWith(t)}),t.children}}(t),o=new class{#L=new Map;register(e,t){this.#L.has(t)||this.#L.set(t,e)}indeterminate(e,t=null){this.#A(e).indeterminate(t)}determinate(e,t,s=null){this.#A(e).determinate(t,s)}success(e,t){this.#A(e).success(t)}failure(e,t){this.#A(e).failure(t)}#A(e){if(!this.#L.has(e))throw new Error(`Could not find progress bar component for signal '${e}'`);return this.#L.get(e)}},l=new Map;e.UI=e.UI||{},e.UI.Progress={},e.UI.Progress.Bar={indeterminate:(e,t)=>o.indeterminate(e,t),success:(e,t)=>o.success(e,t),failure:(e,t)=>o.failure(e,t),determinate:(e,t,s)=>o.determinate(e,t,s),addUpdateListener:(e,t)=>{if(!l.has(e))throw new Error(`No progress bar with id '${e}' found.`);l.get(e).addUpdateListener(t)},createAsync:(e,s,r)=>{const h=function(e,t,s,r){if(!s.hasAttribute("data-url"))throw new Error('Async progress bar must provide a "data-url" attribute.');return new n(e,t,r,s.getAttribute("data-url"))}(a,i(t,e),e,r);return o.register(h,s),l.set(e.id,h),h},create:(e,s)=>{const r=i(t,e);return o.register(r,s),l.set(e.id,r),r}}}(il,document); diff --git a/components/ILIAS/UI/resources/js/Progress/rollup.config.js b/components/ILIAS/UI/resources/js/Progress/rollup.config.js index fc7fc83be4e2..079890449b0e 100644 --- a/components/ILIAS/UI/resources/js/Progress/rollup.config.js +++ b/components/ILIAS/UI/resources/js/Progress/rollup.config.js @@ -26,19 +26,20 @@ export default { 'ilias', ], output: { - file: './dist/progress.min.js', + file: '../../../../../../public/assets/js/progress.min.js', + // file: './dist/progress.min.js', format: 'iife', globals: { document: 'document', ilias: 'il', }, banner: copyright, - plugins: [ - terser({ - format: { - comments: preserveCopyright, - }, - }), - ], + // plugins: [ + // terser({ + // format: { + // comments: preserveCopyright, + // }, + // }), + // ], }, }; diff --git a/components/ILIAS/UI/resources/js/Progress/src/ProgressBar.js b/components/ILIAS/UI/resources/js/Progress/src/ProgressBar.js index b1c0bb74ed3b..b8b34cd6dde3 100644 --- a/components/ILIAS/UI/resources/js/Progress/src/ProgressBar.js +++ b/components/ILIAS/UI/resources/js/Progress/src/ProgressBar.js @@ -13,6 +13,8 @@ * https://github.com/ILIAS-eLearning */ +import ProgressUpdateEvent from './ProgressUpdateEvent.js'; + /** * @author Thibeau Fuhrer */ @@ -23,6 +25,9 @@ export default class ProgressBar { /** @var {HTMLDivElement} */ #messageElement; + /** @var {Set} */ + #updateListeners = new Set(); + /** * @param {HTMLProgressElement} progressElement * @param {HTMLDivElement} messageElement @@ -32,6 +37,24 @@ export default class ProgressBar { this.#messageElement = messageElement; } + /** + * @param {function(ProgressUpdateEvent)} listener + */ + removeUpdateListener(listener) { + if (this.#updateListeners.has(listener)) { + this.#updateListeners.delete(listener); + } + } + + /** + * @param {function(ProgressUpdateEvent)} listener + */ + addUpdateListener(listener) { + if (!this.#updateListeners.has(listener)) { + this.#updateListeners.add(listener); + } + } + /** * @param {string|null} message */ @@ -42,7 +65,10 @@ export default class ProgressBar { if (message !== null) { this.#showMessage(message); + } else { + this.#hideMessage(); } + this.#broadcastUpdate('indeterminate'); } /** @@ -61,7 +87,10 @@ export default class ProgressBar { if (message !== null) { this.#showMessage(message); + } else { + this.#hideMessage(); } + this.#broadcastUpdate('determinate'); } /** @@ -71,6 +100,7 @@ export default class ProgressBar { if (this.#progressElement.value !== this.#progressElement.max) { this.#finish(message, 'success'); } + this.#broadcastUpdate('success'); } /** @@ -80,6 +110,7 @@ export default class ProgressBar { if (this.#progressElement.value !== this.#progressElement.max) { this.#finish(message, 'failure'); } + this.#broadcastUpdate('failure'); } reset() { @@ -119,6 +150,19 @@ export default class ProgressBar { this.#showMessage(message); } + /** + * @param {string} state + */ + #broadcastUpdate(state) { + const event = new ProgressUpdateEvent( + state, + this.#progressElement.value, + this.#messageElement.textContent, + ); + + this.#updateListeners.forEach((listener) => listener(event)); + } + /** * @param {string} message */ diff --git a/components/ILIAS/UI/resources/js/Progress/src/ProgressBarAsyncDecorator.js b/components/ILIAS/UI/resources/js/Progress/src/ProgressBarAsyncDecorator.js index 4c394dc37219..9c289231b18f 100644 --- a/components/ILIAS/UI/resources/js/Progress/src/ProgressBarAsyncDecorator.js +++ b/components/ILIAS/UI/resources/js/Progress/src/ProgressBarAsyncDecorator.js @@ -138,6 +138,14 @@ export default class ProgressBarAsyncDecorator { } } + addUpdateListener(listener) { + this.#progressBar.addUpdateListener(listener); + } + + removeUpdateListener(listener) { + this.#progressBar.removeUpdateListener(listener); + } + /** * @param {string|null} message * @see {ProgressBar.indeterminate} diff --git a/components/ILIAS/UI/resources/js/Progress/src/ProgressUpdateEvent.js b/components/ILIAS/UI/resources/js/Progress/src/ProgressUpdateEvent.js new file mode 100644 index 000000000000..d93cb7fd3716 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Progress/src/ProgressUpdateEvent.js @@ -0,0 +1,53 @@ +/** + * 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 + */ +export default class ProgressUpdateEvent { + /** @var {string} */ + #state; + + /** + * @param {string} state (indeterminate|determinate|success|failure) + * @param {number} value + * @param {string|null} message + */ + constructor(state, value, message) { + this.#state = state; + this.value = value; + this.message = message; + } + + /** @return {boolean} */ + isIndeterminate() { + return this.#state === 'indeterminate'; + } + + /** @return {boolean} */ + isDeterminate() { + return this.#state === 'determinate'; + } + + /** @return {boolean} */ + isSuccess() { + return this.#state === 'success'; + } + + /** @return {boolean} */ + isFailure() { + return this.#state === 'failure'; + } +} diff --git a/components/ILIAS/UI/resources/js/Progress/src/facade.js b/components/ILIAS/UI/resources/js/Progress/src/facade.js index 63c5a45e9803..e390ed3ddfa1 100644 --- a/components/ILIAS/UI/resources/js/Progress/src/facade.js +++ b/components/ILIAS/UI/resources/js/Progress/src/facade.js @@ -18,12 +18,14 @@ import il from 'ilias'; import document from 'document'; import AsyncRenderer from '../../Core/src/AsyncRenderer.js'; -import createProgressBar from './createProgressBar'; +import createProgressBar from './createProgressBar.js'; import createAsyncProgressBar from './createAsyncProgressBar.js'; import GlobalProgressBarSignalDispatcher from './GlobalProgressBarSignalDispatcher.js'; const asyncRenderer = new AsyncRenderer(document); const signalDispatcher = new GlobalProgressBarSignalDispatcher(); +/** @var {Map} */ +const progressBarMap = new Map(); il.UI = il.UI || {}; il.UI.Progress = {}; @@ -37,6 +39,12 @@ il.UI.Progress.Bar = { progress, message, ), + addUpdateListener: (id, listener) => { + if (!progressBarMap.has(id)) { + throw new Error(`No progress bar with id '${id}' found.`); + } + progressBarMap.get(id).addUpdateListener(listener); + }, createAsync: (element, updateSignal, refreshRateInMs) => { const asyncProgressBar = createAsyncProgressBar( asyncRenderer, @@ -45,11 +53,13 @@ il.UI.Progress.Bar = { refreshRateInMs, ); signalDispatcher.register(asyncProgressBar, updateSignal); + progressBarMap.set(element.id, asyncProgressBar); return asyncProgressBar; }, create: (element, updateSignal) => { const progressBar = createProgressBar(document, element); signalDispatcher.register(progressBar, updateSignal); + progressBarMap.set(element.id, progressBar); return progressBar; }, }; diff --git a/components/ILIAS/UI/src/Component/Progress/State/Bar/Status.php b/components/ILIAS/UI/src/Component/Progress/State/Bar/Status.php new file mode 100644 index 000000000000..21a90551f070 --- /dev/null +++ b/components/ILIAS/UI/src/Component/Progress/State/Bar/Status.php @@ -0,0 +1,43 @@ + + */ +enum Status: string +{ + /** The progress of the process/task cannot be calculated (yet), but it has started processing. */ + case INDETERMINATE = 'indeterminate'; + + /** The progress of the process/task can be calculated and has been provided. */ + case DETERMINATE = 'determinate'; + + /** The process/task finished without errors. */ + case SUCCESS = 'success'; + + /** The process/task could not be finished, or finished with errors. */ + case FAILURE = 'failure'; +} diff --git a/components/ILIAS/UI/src/examples/Progress/Bar/server.php b/components/ILIAS/UI/src/examples/Progress/Bar/server.php index 1a635bda86f1..5f6459058454 100644 --- a/components/ILIAS/UI/src/examples/Progress/Bar/server.php +++ b/components/ILIAS/UI/src/examples/Progress/Bar/server.php @@ -55,6 +55,9 @@ function server(): string $endpoint_url = $data_factory->uri($endpoint_url); $progress_bar = $factory->progress()->bar('waiting about 10 seconds', $endpoint_url); + $progress_bar = $progress_bar->withOnUpdate(static fn($id) => " + il.UI.Progress.Bar. + "); $trigger = $factory->button()->standard('start making progress', '#'); $trigger = $trigger->withAdditionalOnLoadCode(