diff --git a/.eslintrc.json b/.eslintrc.json index 04a297f376ad..229654dc4428 100755 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -58,6 +58,8 @@ { "props": false } - ] + ], + // allow destructuring and declarations of simple objects on one line. + "object-curly-newline": 0 } } diff --git a/components/ILIAS/MetaData/classes/OERHarvester/ControlCenter/Content/ContentFactory.php b/components/ILIAS/MetaData/classes/OERHarvester/ControlCenter/Content/ContentFactory.php index 5ea3a541f7a1..d9e6889320de 100644 --- a/components/ILIAS/MetaData/classes/OERHarvester/ControlCenter/Content/ContentFactory.php +++ b/components/ILIAS/MetaData/classes/OERHarvester/ControlCenter/Content/ContentFactory.php @@ -126,8 +126,8 @@ protected function getActions( if (!$disabled) { $button = $button->withOnLoadCode( fn($id) => "$('#$id').on('click', (e)=> { - let promptId = e.target.closest('.il-prompt').id; - il.UI.prompt.get(promptId).show('$link'); + let promptId = e.target.closest('.c-prompt').id; + il.UI.Prompt.get(promptId).loadState('$link'); });" ); } @@ -176,8 +176,8 @@ public function getConfirmationShowState( $button = $this->ui_factory->button()->standard($this->presentation_utilities->txt('confirm'), '#'); $button = $button->withOnLoadCode( fn($id) => "$('#$id').on('click', (e)=> { - let promptId = e.target.closest('.il-prompt').id; - il.UI.prompt.get(promptId).show('$action'); + let promptId = e.target.closest('.c-prompt').id; + il.UI.Prompt.get(promptId).loadState('$action'); });" ); return $this->ui_factory->prompt()->state()->show( diff --git a/components/ILIAS/UI/UI.php b/components/ILIAS/UI/UI.php index 1fd13c1e888f..31fe72b6b9a7 100644 --- a/components/ILIAS/UI/UI.php +++ b/components/ILIAS/UI/UI.php @@ -469,6 +469,7 @@ public function init( $internal[UI\Implementation\Component\Prompt\Factory::class] = static fn() => new UI\Implementation\Component\Prompt\Factory( $internal[UI\Implementation\Component\SignalGeneratorInterface::class], + $internal[UI\Implementation\Component\Prompt\State\Factory::class], ); $internal[UI\Implementation\Component\Prompt\State\Factory::class] = static fn() => new UI\Implementation\Component\Prompt\State\Factory(); @@ -492,6 +493,7 @@ public function init( $internal[UI\Implementation\Render\ResourceRegistry::class], new UI\Implementation\Render\FSLoader( new UI\Implementation\Render\DefaultRendererFactory( + $internal[UI\Implementation\Render\ContextMatcher::class], $use[UI\Implementation\FactoryInternal::class], $internal[UI\Implementation\Render\TemplateFactory::class], $use[Language\Language::class], @@ -502,6 +504,7 @@ public function init( $internal[UI\Implementation\Component\Input\UploadLimitResolver::class], ), new UI\Implementation\Component\Button\ButtonRendererFactory( + $internal[UI\Implementation\Render\ContextMatcher::class], $use[UI\Implementation\FactoryInternal::class], $internal[UI\Implementation\Render\TemplateFactory::class], $use[Language\Language::class], @@ -512,6 +515,7 @@ public function init( $internal[UI\Implementation\Component\Input\UploadLimitResolver::class], ), new UI\Implementation\Component\Input\Field\FieldRendererFactory( + $internal[UI\Implementation\Render\ContextMatcher::class], $use[UI\Implementation\FactoryInternal::class], $internal[UI\Implementation\Render\TemplateFactory::class], $use[Language\Language::class], @@ -522,6 +526,7 @@ public function init( $internal[UI\Implementation\Component\Input\UploadLimitResolver::class], ), new UI\Implementation\Component\MessageBox\MessageBoxRendererFactory( + $internal[UI\Implementation\Render\ContextMatcher::class], $use[UI\Implementation\FactoryInternal::class], $internal[UI\Implementation\Render\TemplateFactory::class], $use[Language\Language::class], @@ -532,6 +537,7 @@ public function init( $internal[UI\Implementation\Component\Input\UploadLimitResolver::class], ), new UI\Implementation\Component\Input\Container\Form\FormRendererFactory( + $internal[UI\Implementation\Render\ContextMatcher::class], $use[UI\Implementation\FactoryInternal::class], $internal[UI\Implementation\Render\TemplateFactory::class], $use[Language\Language::class], @@ -542,6 +548,18 @@ public function init( $internal[UI\Implementation\Component\Input\UploadLimitResolver::class], ), new UI\Implementation\Component\Menu\MenuRendererFactory( + $internal[UI\Implementation\Render\ContextMatcher::class], + $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], + ), + new UI\Implementation\Component\Entity\EntityRendererFactory( + $internal[UI\Implementation\Render\ContextMatcher::class], $use[UI\Implementation\FactoryInternal::class], $internal[UI\Implementation\Render\TemplateFactory::class], $use[Language\Language::class], @@ -552,6 +570,7 @@ public function init( $internal[UI\Implementation\Component\Input\UploadLimitResolver::class], ), new UI\Implementation\Component\Listing\ListingRendererFactory( + $internal[UI\Implementation\Render\ContextMatcher::class], $use[UI\Implementation\FactoryInternal::class], $internal[UI\Implementation\Render\TemplateFactory::class], $use[Language\Language::class], @@ -564,6 +583,8 @@ public function init( ) ) ); + $internal[UI\Implementation\Render\ContextMatcher::class] = static fn() => + new UI\Implementation\Render\ContextMatcher(); $internal[UI\Implementation\Render\JavaScriptBinding::class] = static fn() => new UI\Implementation\Render\ilJavaScriptBinding( $use[UICore\GlobalTemplate::class], diff --git a/components/ILIAS/UI/resources/js/Core/rollup.config.js b/components/ILIAS/UI/resources/js/Core/rollup.config.js index 17d047780365..0605ed9215c5 100755 --- a/components/ILIAS/UI/resources/js/Core/rollup.config.js +++ b/components/ILIAS/UI/resources/js/Core/rollup.config.js @@ -22,6 +22,7 @@ export default { ], input: './src/core.js', output: { + // file: '../../../../../../public/assets/js/core.js', file: './dist/core.js', format: 'iife', banner: copyright, diff --git a/components/ILIAS/UI/resources/js/Core/src/AsyncRenderer.js b/components/ILIAS/UI/resources/js/Core/src/AsyncRenderer.js index 1f5f959e6b3a..674d38b19e7e 100644 --- a/components/ILIAS/UI/resources/js/Core/src/AsyncRenderer.js +++ b/components/ILIAS/UI/resources/js/Core/src/AsyncRenderer.js @@ -47,11 +47,12 @@ export default class AsyncRenderer { * root element. * * @param {URL|string} url + * @param {RequestInit} [request={}] * @returns {Promise} * @throws {Error} if the request with fetch() failed. */ - loadContent(url) { - return fetch(url.toString()) + loadContent(url, request = {}) { + return fetch(url.toString(), request) .then((response) => response.text()) .then((html) => this.#createElements(html)) .then((elements) => createDocumentFragment(this.#document, elements)) diff --git a/components/ILIAS/UI/resources/js/Core/src/createHashCode.js b/components/ILIAS/UI/resources/js/Core/src/createHashCode.js new file mode 100644 index 000000000000..af5685e6a8d8 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Core/src/createHashCode.js @@ -0,0 +1,90 @@ +/** + * 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 + */ + +/** + * Returns a hash code which is NOT cryptographically secure. + * This can be used in contexts where Window.crypto is not available. + * + * The current algorithm is based on Paul Hsieh's SuperFastHash which + * has been translated to JavaScript. Unlike the original algorithm, + * this function returns a fixed-width string instead of an unsigned + * integer. + * + * @see http://www.azillionmonkeys.com/qed/hash.html + * + * @param {string} string + * @returns {string} + */ +export default function createHashCode(string) { + /* eslint-disable no-bitwise */ + + const bytes = new TextEncoder().encode(string); + + let len = bytes.length; + if (len <= 0) return 0; + + let hash = len >>> 0; + let tmp; + let i = 0; + + const get16bits = (p) => bytes[p] | (bytes[p + 1] << 8); + + const rem = len & 3; + len >>>= 2; + + // main loop + for (; len > 0; len -= 1) { + hash = (hash + get16bits(i)) >>> 0; + tmp = ((get16bits(i + 2) << 11) ^ hash) >>> 0; + hash = ((hash << 16) ^ tmp) >>> 0; + i += 4; + hash = (hash + (hash >>> 11)) >>> 0; + } + + // handle end cases + switch (rem) { + case 3: + hash = (hash + get16bits(i)) >>> 0; + hash = (hash ^ (hash << 16)) >>> 0; + // (signed char) cast: sign-extend the byte + hash = (hash ^ (((bytes[i + 2] << 24) >> 24) << 18)) >>> 0; + hash = (hash + (hash >>> 11)) >>> 0; + break; + case 2: + hash = (hash + get16bits(i)) >>> 0; + hash = (hash ^ (hash << 11)) >>> 0; + hash = (hash + (hash >>> 17)) >>> 0; + break; + case 1: + hash = (hash + ((bytes[i] << 24) >> 24)) >>> 0; + hash = (hash ^ (hash << 10)) >>> 0; + hash = (hash + (hash >>> 1)) >>> 0; + break; + default: + // end case valid + break; + } + + // force "avalanching" of final 127 bits + hash = (hash ^ (hash << 3)) >>> 0; + hash = (hash + (hash >>> 5)) >>> 0; + hash = (hash ^ (hash << 4)) >>> 0; + hash = (hash + (hash >>> 17)) >>> 0; + hash = (hash ^ (hash << 25)) >>> 0; + hash = (hash + (hash >>> 6)) >>> 0; + + // return fixed-width 8 character hex string + return hash.toString(16).padStart(8, '0'); +} diff --git a/components/ILIAS/UI/resources/js/Input/Field/file.js b/components/ILIAS/UI/resources/js/Input/Field/file.js index f27e30785694..f02587c8ae2f 100755 --- a/components/ILIAS/UI/resources/js/Input/Field/file.js +++ b/components/ILIAS/UI/resources/js/Input/Field/file.js @@ -18,7 +18,7 @@ * @author Thibeau Fuhrer * * @TODO: dropzone.js library can easily be dropped, the only thing - * it still does is keeping track of files and uploading them. + * it still does is keeping track of files and uploading them. */ // global dropzone.js setting @@ -28,74 +28,74 @@ var il = il || {}; il.UI = il.UI || {}; il.UI.Input = il.UI.Input || {}; (function ($, Input) { - Input.File = (function ($) { - /** - * Holds a list of all the query selectors used throughout this script. - * @type {{}} - */ - const SELECTOR = { - file_input: 'fieldset', - file_fieldset: '[data-il-ui-component="file-field-input"]', - image_fieldset: '[data-il-ui-component="image-field-input"]', - file_list: '.ui-input-file-input-list', - file_list_entry: '.ui-input-file-input', - file_entry_metadata: '.ui-input-file-metadata', - file_entry_input: 'input[type="hidden"]', - - dropzone: '.ui-input-file-input-dropzone', - error_message: '.ui-input-file-input-error-msg', - removal_button: '[data-action="remove"] button', - expand_button: '[data-action="expand"] button', - collapse_button: '[data-action="collapse"] button', - form_submit_buttons: '.c-form_actions > button', - modal_form_controls: '.modal-footer button', - - progress_container: '.ui-input-file-input-progress-container', - progress_indicator: '.ui-input-file-input-progress-indicator', - }; - - /** - * Holds a list of translated messages that could be displayed to humans. - * @type {{}} - */ - let I18N = { - invalid_mime: `Files of type '%s' are not allowed`, - invalid_size: `File exceeds the maximum size of %s.`, - invalid_amount: `You cannot upload this many files, please remove some in order to continue.`, - general_error: `An error occurred, check the console for more information.`, - } - - /** - * Holds whether the global event listeners were added. - * @type {boolean} - */ - let instantiated = false; - - /** - * When a form is processed that holds an instance of this input, - * this holds the total amount of dropzones in it. - * @type {int} - */ - let current_dropzone_count = 0; - - /** - * When a form is processed that holds an instance of this input, - * this keeps track of how many of the dropzones were processed. - * @type {int} - */ - let current_dropzone = 0; - - /** - * Holds a list of Dropzone instances mapped to the file input id. - * @type {Dropzone[]} - */ - let dropzones = []; - - /** - * Holds a list of Files per file input id to remove before sending the form - * @type string[] - */ - let removal_items = []; + Input.File = (function ($) { + /** + * Holds a list of all the query selectors used throughout this script. + * @type {{}} + */ + const SELECTOR = { + file_input: 'fieldset', + file_fieldset: '[data-il-ui-component="file-field-input"]', + image_fieldset: '[data-il-ui-component="image-field-input"]', + file_list: '.ui-input-file-input-list', + file_list_entry: '.ui-input-file-input', + file_entry_metadata: '.ui-input-file-metadata', + file_entry_input: 'input[type="hidden"]', + + dropzone: '.ui-input-file-input-dropzone', + error_message: '.ui-input-file-input-error-msg', + removal_button: '[data-action="remove"] button', + expand_button: '[data-action="expand"] button', + collapse_button: '[data-action="collapse"] button', + form_submit_buttons: '.c-form_actions > button', + modal_form_controls: '.modal-footer button', + + progress_container: '.ui-input-file-input-progress-container', + progress_indicator: '.ui-input-file-input-progress-indicator', + }; + + /** + * Holds a list of translated messages that could be displayed to humans. + * @type {{}} + */ + let I18N = { + invalid_mime: 'Files of type \'%s\' are not allowed', + invalid_size: 'File exceeds the maximum size of %s.', + invalid_amount: 'You cannot upload this many files, please remove some in order to continue.', + general_error: 'An error occurred, check the console for more information.', + }; + + /** + * Holds whether the global event listeners were added. + * @type {boolean} + */ + let instantiated = false; + + /** + * When a form is processed that holds an instance of this input, + * this holds the total amount of dropzones in it. + * @type {int} + */ + let current_dropzone_count = 0; + + /** + * When a form is processed that holds an instance of this input, + * this keeps track of how many of the dropzones were processed. + * @type {int} + */ + let current_dropzone = 0; + + /** + * Holds a list of Dropzone instances mapped to the file input id. + * @type {Dropzone[]} + */ + const dropzones = []; + + /** + * Holds a list of Files per file input id to remove before sending the form + * @type string[] + */ + const removal_items = []; /** * Current form element and submitter for @@ -107,116 +107,129 @@ il.UI.Input = il.UI.Input || {}; * Holds initialised forms, prevents duplicate submission handlers. * @type {WeakSet} */ - let initialised_forms_set = new WeakSet(); - - /** - * @param {string} input_id - * @param {string} upload_url - * @param {string} removal_url - * @param {string} file_identifier - * @param {int} current_file_count - * @param {int} max_file_amount - * @param {int} max_file_size_in_bytes - * @param {string} mime_types - * @param {boolean} is_disabled - * @param {string[]} translations - */ - let init = function ( - input_id, - upload_url, - removal_url, - file_identifier, - current_file_count, - max_file_amount, - max_file_size_in_bytes, - mime_types, - is_disabled, - translations, - should_upload_be_chunked, - chunk_size_in_bytes - ) { - if (typeof dropzones[input_id] !== 'undefined') { - console.error(`Error: tried to register input '${input_id}' as file input twice.`); - return; - } - - if (is_disabled) { - disableActionButton(input_id); - return; - } - - I18N = Object.assign(translations); - - // retrieve file-list and action button in vanilla js, - // because of dropzone.js compatibility. - let file_list = document.querySelector(`#${input_id} ${SELECTOR.file_list}`); - let action_button = document.querySelector(`#${input_id} ${SELECTOR.dropzone} button`); - const previewTemplate = document.querySelector(`#${input_id} template`); - - removal_items[input_id] = []; - - dropzones[input_id] = new Dropzone( - `#${input_id} ${SELECTOR.dropzone}`, - { - url: encodeURI(upload_url), - uploadMultiple: (!should_upload_be_chunked && 1 < max_file_amount), - acceptedFiles: (0 < mime_types.length) ? mime_types : null, - maxFiles: max_file_amount, - maxFilesize: bytesToMiB(max_file_size_in_bytes), // official dropzone.js docu is wrong, MiB is expected. - previewsContainer: file_list, - customPreviewTemplate: previewTemplate, - clickable: action_button, - autoProcessQueue: false, - parallelUploads: 1, - current_file_count: current_file_count, - file_identifier: file_identifier, - removal_url: removal_url, - input_id: input_id, - chunking: should_upload_be_chunked, - forceChunking: should_upload_be_chunked, - chunkSize: chunk_size_in_bytes, - form: action_button.closest('form'), - - // override default rendering function. - addedfile: file => { - renderFileEntryHook(file, input_id); - }, - } - ); - - dropzones[input_id].options.form.should_submit = true; - - initGlobalFileEventListeners(); - initDropzoneEventListeners(dropzones[input_id]); - maybeToggleActionButtonAndErrorMessage(input_id); - setupExpansionGlyphs(); - } - - let initGlobalFileEventListeners = function () { - // abort if the global event listeners were already added. - if (instantiated) { - return; - } - - $(document).on('click', - `${SELECTOR.file_list} ${SELECTOR.collapse_button}, ${SELECTOR.file_list} ${SELECTOR.expand_button}`, - toggleMetadataInputsHook); - - $(document).on('click', - `${SELECTOR.file_list} ${SELECTOR.collapse_button}, ${SELECTOR.file_list} ${SELECTOR.expand_button}`, - toggleExpansionGlyphsHook); - - $(document).on('click', - `${SELECTOR.file_list} ${SELECTOR.removal_button}`, - removeFileManuallyHook); - - instantiated = true; - } - - /** - * @param {Dropzone} dropzone - */ - let initDropzoneEventListeners = function (dropzone) { + const initialised_forms_set = new WeakSet(); + + /** + * Holds uploaded/finished forms, lets the second (final) submit pass through. + * An entry is consumed (deleted) as soon as the final submit event passed. + * @type {WeakSet} + */ + const finished_forms = new WeakSet(); + + /** + * @param {string} input_id + * @param {string} upload_url + * @param {string} removal_url + * @param {string} file_identifier + * @param {int} current_file_count + * @param {int} max_file_amount + * @param {int} max_file_size_in_bytes + * @param {string} mime_types + * @param {boolean} is_disabled + * @param {string[]} translations + */ + const init = function ( + input_id, + upload_url, + removal_url, + file_identifier, + current_file_count, + max_file_amount, + max_file_size_in_bytes, + mime_types, + is_disabled, + translations, + should_upload_be_chunked, + chunk_size_in_bytes, + ) { + if (typeof dropzones[input_id] !== 'undefined') { + console.error(`Error: tried to register input '${input_id}' as file input twice.`); + return; + } + + if (is_disabled) { + disableActionButton(input_id); + return; + } + + I18N = Object.assign(translations); + + // retrieve file-list and action button in vanilla js, + // because of dropzone.js compatibility. + const file_list = document.querySelector(`#${input_id} ${SELECTOR.file_list}`); + const action_button = document.querySelector(`#${input_id} ${SELECTOR.dropzone} button`); + const previewTemplate = document.querySelector(`#${input_id} template`); + + removal_items[input_id] = []; + + dropzones[input_id] = new Dropzone( + `#${input_id} ${SELECTOR.dropzone}`, + { + url: encodeURI(upload_url), + uploadMultiple: (!should_upload_be_chunked && max_file_amount > 1), + acceptedFiles: (mime_types.length > 0) ? mime_types : null, + maxFiles: max_file_amount, + maxFilesize: bytesToMiB(max_file_size_in_bytes), // official dropzone.js docu is wrong, MiB is expected. + previewsContainer: file_list, + customPreviewTemplate: previewTemplate, + clickable: action_button, + autoProcessQueue: false, + parallelUploads: 1, + current_file_count, + file_identifier, + removal_url, + input_id, + chunking: should_upload_be_chunked, + forceChunking: should_upload_be_chunked, + chunkSize: chunk_size_in_bytes, + form: action_button.closest('form'), + + // override default rendering function. + addedfile: (file) => { + renderFileEntryHook(file, input_id); + }, + }, + ); + + dropzones[input_id].options.form.should_submit = true; + + initGlobalFileEventListeners(); + initDropzoneEventListeners(dropzones[input_id]); + maybeToggleActionButtonAndErrorMessage(input_id); + setupExpansionGlyphs(); + }; + + let initGlobalFileEventListeners = function () { + // abort if the global event listeners were already added. + if (instantiated) { + return; + } + + $(document).on( + 'click', + `${SELECTOR.file_list} ${SELECTOR.collapse_button}, ${SELECTOR.file_list} ${SELECTOR.expand_button}`, + toggleMetadataInputsHook, + ); + + $(document).on( + 'click', + `${SELECTOR.file_list} ${SELECTOR.collapse_button}, ${SELECTOR.file_list} ${SELECTOR.expand_button}`, + toggleExpansionGlyphsHook, + ); + + $(document).on( + 'click', + `${SELECTOR.file_list} ${SELECTOR.removal_button}`, + removeFileManuallyHook, + ); + + instantiated = true; + }; + + /** + * @param {Dropzone} dropzone + */ + let initDropzoneEventListeners = function (dropzone) { const form = document .getElementById(dropzone.options.input_id) .closest('form'); @@ -228,221 +241,267 @@ il.UI.Input = il.UI.Input || {}; initialised_forms_set.add(form); } - dropzone.on('maxfilesexceeded', alertMaxFilesReachedHook); - dropzone.on('maxfilesreached', disableActionButtonHook); - dropzone.on('queuecomplete', () => { - submitCurrentFormHook(dropzone); - }); - dropzone.on('processing', enableAutoProcessingHook); - dropzone.on('success', setResourceStorageIdHook); - dropzone.on('error', function () { - return false; - }); - dropzone.on('uploadprogress', function (file, progress, bytesSent) { - let file_id_input = $(`#${file.input_id}`); - let file_preview = file_id_input.closest(SELECTOR.file_list_entry); - - if (file_preview) { - let progressContainer = file_preview.find(SELECTOR.progress_container); - let progressIndicator = file_preview.find(SELECTOR.progress_indicator); - let number = Math.round(progress); - - if (number === 100 && bytesSent < file.size) { - // return; - } - if (progressContainer && progressIndicator) { - progressContainer.css('display', 'block'); - if (!file.hasOwnProperty('progress_storage') || number > file.progress_storage) { - progressIndicator.css('width', number + '%'); - } - if (number === 100) { - progressIndicator.addClass('success'); - } - } - file.progress_storage = number; - } - }); - } - - // ========================================== - // BEGIN global event hooks - // ========================================== - - /** - * @param {Event} event - */ - let removeFileManuallyHook = function (event) { - event.preventDefault(); - let removal_button = $(this); - let input_id = removal_button.closest(SELECTOR.file_input).attr('id'); - let dropzone = dropzones[input_id]; - - if (typeof dropzone === 'undefined') { - console.error(`Error: tried to remove file from uninitialized input: '${input_id}'`); - return; - } - - let file_entry = removal_button.closest(SELECTOR.file_list_entry); - let file_entry_input = getFileEntryInput(file_entry); - - dropzone.options.autoProcessQueue = false; - dropzone.options.form.should_submit = false; - dropzone.options.current_file_count--; - maybeRemoveFileFromQueue(dropzone, file_entry_input.attr('id')); - maybeToggleActionButtonAndErrorMessage(input_id); - setFormControlsDisabledState(dropzone.options.form, false); - file_entry.remove(); - - // only remove files that have a file id and are therefore stored - // on the server. - if ('' === file_entry_input.val()) { - return; - } - - // stop event propagation as there may occur an error. - event.stopImmediatePropagation(); - } - - /** - * @param {Dropzone} dropzone - * @param {SubmitEvent} event - */ - let processFormSubmissionHook = function (dropzone, event) { - // emitter will be an HTMLFormElement, but once the proper emitter is set - // for NoSubmit signals, this can also be an HTMLButtonElement. - dropzone.options.form.should_submit = true; + dropzone.on('maxfilesexceeded', alertMaxFilesReachedHook); + dropzone.on('maxfilesreached', disableActionButtonHook); + dropzone.on('queuecomplete', () => { + submitCurrentFormHook(dropzone); + }); + dropzone.on('processing', enableAutoProcessingHook); + dropzone.on('success', setResourceStorageIdHook); + dropzone.on('error', (file, message, xhr) => { + console.error('[file.js] dropzone error', dropzone.options.input_id, message, xhr); + return false; + }); + dropzone.on('uploadprogress', (file, progress, bytesSent) => { + const file_id_input = $(`#${file.input_id}`); + const file_preview = file_id_input.closest(SELECTOR.file_list_entry); + + if (file_preview) { + const progressContainer = file_preview.find(SELECTOR.progress_container); + const progressIndicator = file_preview.find(SELECTOR.progress_indicator); + const number = Math.round(progress); + + if (number === 100 && bytesSent < file.size) { + // return; + } + if (progressContainer && progressIndicator) { + progressContainer.css('display', 'block'); + if (!file.hasOwnProperty('progress_storage') || number > file.progress_storage) { + progressIndicator.css('width', `${number}%`); + } + if (number === 100) { + progressIndicator.addClass('success'); + } + } + file.progress_storage = number; + } + }); + }; + + // ========================================== + // BEGIN global event hooks + // ========================================== + + /** + * @param {Event} event + */ + let removeFileManuallyHook = function (event) { + event.preventDefault(); + const removal_button = $(this); + const input_id = removal_button.closest(SELECTOR.file_input).attr('id'); + const dropzone = dropzones[input_id]; + + if (typeof dropzone === 'undefined') { + console.error(`Error: tried to remove file from uninitialized input: '${input_id}'`); + return; + } + + const file_entry = removal_button.closest(SELECTOR.file_list_entry); + const file_entry_input = getFileEntryInput(file_entry); + + dropzone.options.autoProcessQueue = false; + dropzone.options.form.should_submit = false; + dropzone.options.current_file_count--; + maybeRemoveFileFromQueue(dropzone, file_entry_input.attr('id')); + maybeToggleActionButtonAndErrorMessage(input_id); + setFormControlsDisabledState(dropzone.options.form, false); + file_entry.remove(); + + // only remove files that have a file id and are therefore stored + // on the server. + if (file_entry_input.val() === '') { + return; + } + + // stop event propagation as there may occur an error. + event.stopImmediatePropagation(); + }; + + /** + * Handles the submit event of a form holding file inputs. + * + * There are three cases: + * + * 1. The final pass: all uploads are finished and submitCurrentFormHook() + * called form.requestSubmit(). The event must NOT be prevented, so it + * bubbles up (e.g. to the Prompt) with defaultPrevented === false. + * 2. Nothing to upload: the very same (original) event is let through. + * We must not call requestSubmit() here, because calling it + * synchronously from within a submit listener is a silent no-op + * (the form's "firing submission events" flag is still set). + * 3. Files to upload: the event is prevented, the uploads are processed + * asynchronously and afterwards requestSubmit() is called (case 1). + * + * @param {Dropzone} dropzone + * @param {SubmitEvent} event + */ + let processFormSubmissionHook = function (dropzone, event) { + const { form } = dropzone.options; + + // case 1: final pass, consume the marker and let the event bubble. + if (finished_forms.has(form)) { + finished_forms.delete(form); + return; + } + form.should_submit = true; submitter_button = event.submitter; - event.preventDefault(); - setFormControlsDisabledState(dropzone.options.form, true); - processCurrentFormDropzones(dropzone.options.form, event); - } - - let toggleExpansionGlyphsHook = function (event) { - event.preventDefault(); - let current_button = $(this); - let action_container = current_button.closest('[data-action]'); - - let other_button = action_container.data('action') === 'expand' ? - current_button.closest(SELECTOR.file_list_entry).find(SELECTOR.collapse_button) : - current_button.closest(SELECTOR.file_list_entry).find(SELECTOR.expand_button) - ; - - other_button.show(); - action_container.hide(); - } - - let toggleMetadataInputsHook = function (event) { - event.preventDefault(); - $(this) - .closest(SELECTOR.file_list_entry) - .find(SELECTOR.file_entry_metadata) - .toggle(); - } - - // ========================================== - // END global event hooks - // ========================================== - - // ========================================== - // BEGIN dropzone event hooks - // ========================================== - - /** - * @param {File} file - * @param {string} input_id - */ - let renderFileEntryHook = function (file, input_id) { - if (typeof dropzones[input_id] === 'undefined') { - console.error(`Error: tried rendering a file entry for '${input_id}' which is not yet initialized.`); - return; - } - - // abort if the given file is not an allowed file type. - if (dropzones[input_id].options.acceptedFiles && - !Dropzone.isValidFile(file, dropzones[input_id].options.acceptedFiles) + const dropzones_to_process = getDropzonesToProcess(form); + + // case 2: nothing to upload, let the original event through. + if (dropzones_to_process.size < 1) { + return; + } + + // case 3: upload first, submit again afterwards. + event.preventDefault(); + setFormControlsDisabledState(form, true); + + current_dropzone_count = dropzones_to_process.size; + current_dropzone = 0; + + dropzones_to_process.forEach((dz, input_id) => { + processRemovals(input_id, event); + dz.processQueue(); + }); + }; + + let toggleExpansionGlyphsHook = function (event) { + event.preventDefault(); + const current_button = $(this); + const action_container = current_button.closest('[data-action]'); + + const other_button = action_container.data('action') === 'expand' + ? current_button.closest(SELECTOR.file_list_entry).find(SELECTOR.collapse_button) + : current_button.closest(SELECTOR.file_list_entry).find(SELECTOR.expand_button); + other_button.show(); + action_container.hide(); + }; + + let toggleMetadataInputsHook = function (event) { + event.preventDefault(); + $(this) + .closest(SELECTOR.file_list_entry) + .find(SELECTOR.file_entry_metadata) + .toggle(); + }; + + // ========================================== + // END global event hooks + // ========================================== + + // ========================================== + // BEGIN dropzone event hooks + // ========================================== + + /** + * @param {File} file + * @param {string} input_id + */ + let renderFileEntryHook = function (file, input_id) { + if (typeof dropzones[input_id] === 'undefined') { + console.error(`Error: tried rendering a file entry for '${input_id}' which is not yet initialized.`); + return; + } + + // abort if the given file is not an allowed file type. + if (dropzones[input_id].options.acceptedFiles + && !Dropzone.isValidFile(file, dropzones[input_id].options.acceptedFiles) ) { - displayErrorMessage( - I18N.invalid_mime.replace('%s', file.type), - $(`#${input_id} ${SELECTOR.dropzone}`) - ); - - // we need to remove the file manually from the dropzone becausee - // it (mistakenly?) gets added anyhow. - dropzones[input_id].removeFile(file); - - return; - } - - // abort if the given file size exceeds the max limit. - if ((dropzones[input_id].options.maxFilesize * 1024 * 1024) < file.size) { - let allowed_file_size = dropzones[input_id].filesize(dropzones[input_id].options.maxFilesize * 1024 * 1024); - displayErrorMessage( - I18N.invalid_size.replace('%s', allowed_file_size), - $(`#${input_id} ${SELECTOR.dropzone}`) - ); - - // we need to remove the file manually from the dropzone becausee - // it (mistakenly?) gets added anyhow. - dropzones[input_id].removeFile(file); - - return; - } - - let preview = il.UI.core.TemplateRenderer.createContent(dropzones[input_id].options.customPreviewTemplate); - - // add file info to preview and setup expansion toggles. - preview.querySelector('[data-dz-name]').innerText = file.name; - preview.querySelector('[data-dz-size]').innerHTML = dropzones[input_id].filesize(file.size); - dropzones[input_id].options.previewsContainer.append(...preview.children); - preview = $(dropzones[input_id].options.previewsContainer.lastElementChild); - setupExpansionGlyphs(preview); - - // store rendered preview id temporarily in file, to retrieve - // the corresponding input later. - file.input_id = getFileEntryInput(preview).attr('id'); - dropzones[input_id].options.current_file_count++; - - // enqueue file to dropzone - if (typeof file.status === 'undefined' || file.status !== Dropzone.ADDED) { - registerDropzoneFile(dropzones[input_id], file); - } - - maybeToggleActionButtonAndErrorMessage(input_id); - } - - /** - * @param {File} file - * @param {string} json_response - */ - let setResourceStorageIdHook = function (file, json_response) { - let response = Object.assign(JSON.parse(json_response)); - let file_id_input = $(`#${file.input_id}`); - let file_preview = file_id_input.closest(SELECTOR.file_list_entry); - let dropzone = dropzones[file_id_input.closest(SELECTOR.file_input).attr('id')]; - - if (typeof response.status === 'undefined' || 1 !== response.status) { - dropzone.options.form.should_submit = false; - response.responseText = response.message; - ajaxResponseFailureHook(response, file_preview); - return false; - } - - // set the upload results IRSS file id. - file_id_input.val(response[dropzone.options.file_identifier]); - } - - let submitCurrentFormHook = function (dropzone) { + displayErrorMessage( + I18N.invalid_mime.replace('%s', file.type), + $(`#${input_id} ${SELECTOR.dropzone}`), + ); + + // we need to remove the file manually from the dropzone becausee + // it (mistakenly?) gets added anyhow. + dropzones[input_id].removeFile(file); + + return; + } + + // abort if the given file size exceeds the max limit. + if ((dropzones[input_id].options.maxFilesize * 1024 * 1024) < file.size) { + const allowed_file_size = dropzones[input_id].filesize(dropzones[input_id].options.maxFilesize * 1024 * 1024); + displayErrorMessage( + I18N.invalid_size.replace('%s', allowed_file_size), + $(`#${input_id} ${SELECTOR.dropzone}`), + ); + + // we need to remove the file manually from the dropzone becausee + // it (mistakenly?) gets added anyhow. + dropzones[input_id].removeFile(file); + + return; + } + + let preview = il.UI.core.TemplateRenderer.createContent(dropzones[input_id].options.customPreviewTemplate); + + // add file info to preview and setup expansion toggles. + preview.querySelector('[data-dz-name]').innerText = file.name; + preview.querySelector('[data-dz-size]').innerHTML = dropzones[input_id].filesize(file.size); + dropzones[input_id].options.previewsContainer.append(...preview.children); + preview = $(dropzones[input_id].options.previewsContainer.lastElementChild); + setupExpansionGlyphs(preview); + + // store rendered preview id temporarily in file, to retrieve + // the corresponding input later. + file.input_id = getFileEntryInput(preview).attr('id'); + dropzones[input_id].options.current_file_count++; + + // enqueue file to dropzone + if (typeof file.status === 'undefined' || file.status !== Dropzone.ADDED) { + registerDropzoneFile(dropzones[input_id], file); + } + + maybeToggleActionButtonAndErrorMessage(input_id); + }; + + /** + * @param {File} file + * @param {string} json_response + */ + let setResourceStorageIdHook = function (file, json_response) { + const response = Object.assign(JSON.parse(json_response)); + const file_id_input = $(`#${file.input_id}`); + const file_preview = file_id_input.closest(SELECTOR.file_list_entry); + const dropzone = dropzones[file_id_input.closest(SELECTOR.file_input).attr('id')]; + + if (typeof response.status === 'undefined' || response.status !== 1) { + dropzone.options.form.should_submit = false; + response.responseText = response.message; + ajaxResponseFailureHook(response, file_preview); + return false; + } + + // set the upload results IRSS file id. + file_id_input.val(response[dropzone.options.file_identifier]); + }; + + /** + * Called asynchronously (after uploads finished), therefore calling + * requestSubmit() here really fires a new submit event. + * + * @param {Dropzone} dropzone + */ + let submitCurrentFormHook = function (dropzone) { // mark one dropzone as finished current_dropzone++; - // submit the current form only if all dropzones - // were processed. - if (dropzone.options.form.should_submit === true && current_dropzone >= current_dropzone_count) { + // submit the current form only if all dropzones + // were processed. + if (dropzone.options.form.should_submit === true && current_dropzone >= current_dropzone_count) { + current_dropzone = 0; setSubmitterButtonFormAction(dropzone.options.form); - dropzone.options.form.submit(); - } - } + // lets the upcoming submit event pass through (and consumes the marker). + finished_forms.add(dropzone.options.form); + try { + dropzone.options.form.requestSubmit(submitter_button); + } catch (error) { + finished_forms.delete(dropzone.options.form); + } + } + }; let setSubmitterButtonFormAction = function (form) { // The check about the submitter having the attribute 'formaction' needs to stay @@ -452,350 +511,348 @@ il.UI.Input = il.UI.Input || {}; if (submitter_button && submitter_button.hasAttribute('formaction')) { form.action = submitter_button.formAction; } - } - - let enableAutoProcessingHook = function () { - let dropzone = $(this)[0]; - - // if there are more than one file in the current - // dropzone's queue, the auto-processing can be - // enabled after the first file was processed. - if (1 !== dropzone.files.length) { - dropzone.options.autoProcessQueue = true; - } - } - - let alertMaxFilesReachedHook = function () { - let input_id = $(this)[0].options.input_id; - displayMaxFilesReachedMessage(input_id); - } - - let disableActionButtonHook = function () { - let input_id = $(this)[0].options.input_id; - disableActionButton(input_id); - } - - /** - * @param {string} json_response - * @param {jQuery} file_preview - */ - let ajaxResponseSuccessHook = function (json_response, file_preview) { - let response = Object.assign(JSON.parse(json_response)); - - // if the delivered response status is not 1 an - // error occurred and the failure hook is fired. - if (typeof response.status === 'undefined' || 1 !== response.status) { - displayErrorMessage(response.message, file_preview); - } - } - - /** - * @param {jQuery.jqXHR} response - * @param {jQuery} file_preview - */ - let ajaxResponseFailureHook = function (response, file_preview) { - console.error(response.status, response.responseText); - displayErrorMessage( - response.responseText, - file_preview - ); - } - - // ========================================== - // END dropzone event hooks - // ========================================== - - // ========================================== - // BEGIN helper functions - // ========================================== - - /** - * @param {Dropzone} dropzone - * @param {File} file - */ - let registerDropzoneFile = function (dropzone, file) { - file.status = Dropzone.ADDED; - file.accepted = true; - file.upload = { - uuid: Dropzone.uuidv4(), - progress: 0, - bytesSent: 0, - total: file.size, - filename: dropzone._renameFile(file), - chunked: dropzone.options.chunking && (dropzone.options.forceChunking || file.size > dropzone.options.chunkSize), - totalChunkCount: Math.ceil(file.size / dropzone.options.chunkSize) - }; - - dropzone.files.push(file); - dropzone.enqueueFile(file); - } - - /** - * @param {jQuery|null} file_entry - */ - let setupExpansionGlyphs = function (file_entry = null) { - if (null === file_entry) { - // hide collapse control globally (in file list). - $(`${SELECTOR.file_list} ${SELECTOR.collapse_button}`).hide(); - } else { - // hide collapse control locally (in file entry). - file_entry.find(SELECTOR.collapse_button).hide(); - } - } - - /** - * @param {Dropzone} dropzone - * @param {string} input_id - */ - let maybeRemoveFileFromQueue = function (dropzone, input_id) { - let file_to_remove = null; - for (let i = 0, i_max = dropzone.files.length; i < i_max; i++) { - let current_input_id = dropzone.files[i].input_id; - if (typeof current_input_id !== 'undefined' && current_input_id === input_id) { - file_to_remove = dropzone.files[i]; - break; - } - } - - if (null !== file_to_remove) { - // removes ONE file object at found position. - dropzone.removeFile(file_to_remove); - } - } - - /** - * @param {string} input_id - */ - let cancelFileUpload = function (input_id) { - if (typeof dropzones[input_id] === 'undefined') { - console.error(`Error: tried to access unknown input '${input_id}'.`); - return; - } - - dropzones[input_id].options.autoProcessQueue = false; - dropzones[input_id].options.form.should_submit = false; - - setFormControlsDisabledState(dropzones[input_id].options.form, false); - - for (let i = 0; i < dropzones[input_id].files.length; ++i) { - let file = dropzones[input_id].files[i]; - dropzones[input_id].removeFile(file); - registerDropzoneFile(dropzones[input_id], file); - - // remove any progress indicators - let file_id_input = $(`#${file.input_id}`); - let file_preview = file_id_input.closest(SELECTOR.file_list_entry); - if (file_preview) { - let progressContainer = file_preview.find(SELECTOR.progress_container); - if (progressContainer) { - progressContainer.css('display', 'none'); - } - file.progress_storage = 0; - } - } - }; - - let removeAllFilesFromQueue = function (input_id) { - if (typeof dropzones[input_id] === 'undefined') { - console.error(`Error: tried to access unknown input '${input_id}'.`); - return; - } - - for (let i = 0; i < dropzones[input_id].files.length; ++i) { - dropzones[input_id].options.current_file_count -= 1; - let file = dropzones[input_id].files[i]; - let file_id_input = $(`#${file.input_id}`); - let file_preview = file_id_input.closest(SELECTOR.file_list_entry); - file_preview.remove(); - } - - dropzones[input_id].removeAllFiles(true); - maybeToggleActionButtonAndErrorMessage(input_id); - } - - /** - * @param {string} input_id - */ - let maybeToggleActionButtonAndErrorMessage = function (input_id) { - let current_file_count = dropzones[input_id].options.current_file_count; - let max_file_amount = dropzones[input_id].options.maxFiles; - - if (current_file_count > max_file_amount) { - displayMaxFilesReachedMessage(input_id); - disableSubmitButtons($(`#${input_id}`).closest('form')); - } else { - removeErrorMessage($(`#${input_id} ${SELECTOR.dropzone}`)); - enableSubmitButtons($(`#${input_id}`).closest('form')); - } - - if (current_file_count >= max_file_amount) { - disableActionButton(input_id); - } else { - enableActionButton(input_id); - } - } - - let processRemovals = function (input_id, event) { - let file_to_remove = removal_items[input_id]; - let dropzone = dropzones[input_id]; - for (let i = 0, i_max = file_to_remove.length; i < i_max; i++) { - let file_id = file_to_remove[i]; - $.ajax({ - type: 'GET', - url: dropzone.options.removal_url, - data: { - [dropzone.options.file_identifier]: file_id, - }, - success: json_response => { - - }, - error: json_response => { - - }, - }); - } - } - - let processCurrentFormDropzones = function (form, event) { + }; + + let enableAutoProcessingHook = function () { + const dropzone = $(this)[0]; + + // if there are more than one file in the current + // dropzone's queue, the auto-processing can be + // enabled after the first file was processed. + if (dropzone.files.length !== 1) { + dropzone.options.autoProcessQueue = true; + } + }; + + let alertMaxFilesReachedHook = function () { + const { input_id } = $(this)[0].options; + displayMaxFilesReachedMessage(input_id); + }; + + let disableActionButtonHook = function () { + const { input_id } = $(this)[0].options; + disableActionButton(input_id); + }; + + /** + * @param {string} json_response + * @param {jQuery} file_preview + */ + const ajaxResponseSuccessHook = function (json_response, file_preview) { + const response = Object.assign(JSON.parse(json_response)); + + // if the delivered response status is not 1 an + // error occurred and the failure hook is fired. + if (typeof response.status === 'undefined' || response.status !== 1) { + displayErrorMessage(response.message, file_preview); + } + }; + + /** + * @param {jQuery.jqXHR} response + * @param {jQuery} file_preview + */ + let ajaxResponseFailureHook = function (response, file_preview) { + console.error(response.status, response.responseText); + displayErrorMessage( + response.responseText, + file_preview, + ); + }; + + // ========================================== + // END dropzone event hooks + // ========================================== + + // ========================================== + // BEGIN helper functions + // ========================================== + + /** + * @param {Dropzone} dropzone + * @param {File} file + */ + let registerDropzoneFile = function (dropzone, file) { + file.status = Dropzone.ADDED; + file.accepted = true; + file.upload = { + uuid: Dropzone.uuidv4(), + progress: 0, + bytesSent: 0, + total: file.size, + filename: dropzone._renameFile(file), + chunked: dropzone.options.chunking && (dropzone.options.forceChunking || file.size > dropzone.options.chunkSize), + totalChunkCount: Math.ceil(file.size / dropzone.options.chunkSize), + }; + + dropzone.files.push(file); + dropzone.enqueueFile(file); + }; + + /** + * @param {jQuery|null} file_entry + */ + let setupExpansionGlyphs = function (file_entry = null) { + if (file_entry === null) { + // hide collapse control globally (in file list). + $(`${SELECTOR.file_list} ${SELECTOR.collapse_button}`).hide(); + } else { + // hide collapse control locally (in file entry). + file_entry.find(SELECTOR.collapse_button).hide(); + } + }; + + /** + * @param {Dropzone} dropzone + * @param {string} input_id + */ + let maybeRemoveFileFromQueue = function (dropzone, input_id) { + let file_to_remove = null; + for (let i = 0, i_max = dropzone.files.length; i < i_max; i++) { + const current_input_id = dropzone.files[i].input_id; + if (typeof current_input_id !== 'undefined' && current_input_id === input_id) { + file_to_remove = dropzone.files[i]; + break; + } + } + + if (file_to_remove !== null) { + // removes ONE file object at found position. + dropzone.removeFile(file_to_remove); + } + }; + + /** + * @param {string} input_id + */ + const cancelFileUpload = function (input_id) { + if (typeof dropzones[input_id] === 'undefined') { + console.error(`Error: tried to access unknown input '${input_id}'.`); + return; + } + + dropzones[input_id].options.autoProcessQueue = false; + dropzones[input_id].options.form.should_submit = false; + current_dropzone = 0; + + setFormControlsDisabledState(dropzones[input_id].options.form, false); + + for (let i = 0; i < dropzones[input_id].files.length; ++i) { + const file = dropzones[input_id].files[i]; + dropzones[input_id].removeFile(file); + registerDropzoneFile(dropzones[input_id], file); + + // remove any progress indicators + const file_id_input = $(`#${file.input_id}`); + const file_preview = file_id_input.closest(SELECTOR.file_list_entry); + if (file_preview) { + const progressContainer = file_preview.find(SELECTOR.progress_container); + if (progressContainer) { + progressContainer.css('display', 'none'); + } + file.progress_storage = 0; + } + } + }; + + const removeAllFilesFromQueue = function (input_id) { + if (typeof dropzones[input_id] === 'undefined') { + console.error(`Error: tried to access unknown input '${input_id}'.`); + return; + } + + for (let i = 0; i < dropzones[input_id].files.length; ++i) { + dropzones[input_id].options.current_file_count -= 1; + const file = dropzones[input_id].files[i]; + const file_id_input = $(`#${file.input_id}`); + const file_preview = file_id_input.closest(SELECTOR.file_list_entry); + file_preview.remove(); + } + + dropzones[input_id].removeAllFiles(true); + maybeToggleActionButtonAndErrorMessage(input_id); + }; + + /** + * @param {string} input_id + */ + let maybeToggleActionButtonAndErrorMessage = function (input_id) { + const { current_file_count } = dropzones[input_id].options; + const max_file_amount = dropzones[input_id].options.maxFiles; + + if (current_file_count > max_file_amount) { + displayMaxFilesReachedMessage(input_id); + disableSubmitButtons($(`#${input_id}`).closest('form')); + } else { + removeErrorMessage($(`#${input_id} ${SELECTOR.dropzone}`)); + enableSubmitButtons($(`#${input_id}`).closest('form')); + } + + if (current_file_count >= max_file_amount) { + disableActionButton(input_id); + } else { + enableActionButton(input_id); + } + }; + + const processRemovals = function (input_id, event) { + const file_to_remove = removal_items[input_id]; + const dropzone = dropzones[input_id]; + for (let i = 0, i_max = file_to_remove.length; i < i_max; i++) { + const file_id = file_to_remove[i]; + $.ajax({ + type: 'GET', + url: dropzone.options.removal_url, + data: { + [dropzone.options.file_identifier]: file_id, + }, + success: (json_response) => { + + }, + error: (json_response) => { + + }, + }); + } + }; + + /** + * Returns all dropzones of the given form that have queued files, + * mapped by their file input id. + * + * @param {HTMLFormElement} form + * @returns {Map} + */ + let getDropzonesToProcess = function (form) { // retrieve all file inputs of the current form. const file_inputs = form.querySelectorAll(`${SELECTOR.file_fieldset}, ${SELECTOR.image_fieldset}`); const dropzones_to_process = new Map(); file_inputs.forEach((file_input) => { const dropzone = dropzones[file_input.id]; - if (dropzone.getQueuedFiles().length > 0) { + if (dropzone && dropzone.getQueuedFiles().length > 0) { dropzones_to_process.set(file_input.id, dropzone); } }); - current_dropzone_count = dropzones_to_process.size; - if (current_dropzone_count < 1) { - setSubmitterButtonFormAction(form); - form.submit(); - return; - } + return dropzones_to_process; + }; - dropzones_to_process.forEach((dropzone, input_id) => { - processRemovals(input_id, event); - dropzone.processQueue(); + /** + * @param {HTMLFormElement} form + * @param {boolean} state + */ + let setFormControlsDisabledState = function (form, state) { + // NoSubmit forms will have disconnected buttons, since they will currently + // only be used in modals, this ternary can be used. Note that this will + // most likely break in the future and we should definitely refactor this. + const form_controls = (form.parentNode.classList.contains('modal-body')) + ? form.closest('.modal-content').querySelectorAll(SELECTOR.modal_form_controls) + : form.querySelectorAll(SELECTOR.form_submit_buttons); + + form_controls.forEach((element) => { + // ignores cancel button of modals + if (element instanceof HTMLButtonElement && !element.hasAttribute('data-dismiss')) { + element.disabled = state; + } }); - } - - /** - * @param {HTMLFormElement} form - * @param {boolean} state - */ - let setFormControlsDisabledState = function (form, state) { - // NoSubmit forms will have disconnected buttons, since they will currently - // only be used in modals, this ternary can be used. Note that this will - // most likely break in the future and we should definitely refactor this. - let form_controls = (form.parentNode.classList.contains('modal-body')) ? - form.closest('.modal-content').querySelectorAll(SELECTOR.modal_form_controls) : - form.querySelectorAll(SELECTOR.form_submit_buttons); - - form_controls.forEach(function (element) { - // ignores cancel button of modals - if (element instanceof HTMLButtonElement && !element.hasAttribute('data-dismiss')) { - element.disabled = state; - } - }); - } - - /** - * @param {jQuery} file_entry - * @return {jQuery} the file-id input - */ - let getFileEntryInput = function (file_entry) { - // since there could be multiple hidden inputs in the future (due - // to introduction as UI component) we have to check if it's one - // or more. When multiple are found it's always the last one. - let hidden_inputs = file_entry.find(SELECTOR.file_entry_input); - return (1 < hidden_inputs.length) ? - $(hidden_inputs[hidden_inputs.length - 1]) : - hidden_inputs; - } - - /** - * @param {jQuery} form - */ - let disableSubmitButtons = function (form) { - form - .find(SELECTOR.form_submit_buttons) - .each(function () { - $(this).attr('disabled', true); - }); - } - - /** - * @param {jQuery} form - */ - let enableSubmitButtons = function (form) { - form - .find(SELECTOR.form_submit_buttons) - .each(function () { - $(this).attr('disabled', false); - }); - } - - /** - * @param {string} input_id - */ - let displayMaxFilesReachedMessage = function (input_id) { - displayErrorMessage( - I18N.invalid_amount, - $(`#${input_id} ${SELECTOR.dropzone}`) - ); - } - - /** - * @param {string} input_id - */ - let disableActionButton = function (input_id) { - let action_button = $(`#${input_id} ${SELECTOR.dropzone} button`); - action_button.attr('disabled', true); - } - - /** - * @param {string} input_id - */ - let enableActionButton = function (input_id) { - let action_button = $(`#${input_id} ${SELECTOR.dropzone} button`); - action_button.attr('disabled', false); - } - - /** - * @param {string} message - * @param {jQuery} container - */ - let displayErrorMessage = function (message, container) { - container.find(SELECTOR.error_message).html(message); - container.find(SELECTOR.progress_indicator).addClass('error'); - } - - /** - * @param {jQuery} container - */ - let removeErrorMessage = function (container) { - container.find(SELECTOR.error_message).text(''); - } - - /** - * @param {number} bytes - * @returns {number} - */ - let bytesToMiB = function (bytes) { - return bytes / 1024 / 1024; - } - - // ========================================== - // END helper functions - // ========================================== - - return { - removeAllFilesFromQueue: removeAllFilesFromQueue, - renderFileEntry: renderFileEntryHook, - cancelFleUpload: cancelFileUpload, - init: init, - } - })($) -})($, il.UI.Input); + }; + + /** + * @param {jQuery} file_entry + * @return {jQuery} the file-id input + */ + let getFileEntryInput = function (file_entry) { + // since there could be multiple hidden inputs in the future (due + // to introduction as UI component) we have to check if it's one + // or more. When multiple are found it's always the last one. + const hidden_inputs = file_entry.find(SELECTOR.file_entry_input); + return (hidden_inputs.length > 1) + ? $(hidden_inputs[hidden_inputs.length - 1]) + : hidden_inputs; + }; + + /** + * @param {jQuery} form + */ + let disableSubmitButtons = function (form) { + form + .find(SELECTOR.form_submit_buttons) + .each(function () { + $(this).attr('disabled', true); + }); + }; + + /** + * @param {jQuery} form + */ + let enableSubmitButtons = function (form) { + form + .find(SELECTOR.form_submit_buttons) + .each(function () { + $(this).attr('disabled', false); + }); + }; + + /** + * @param {string} input_id + */ + let displayMaxFilesReachedMessage = function (input_id) { + displayErrorMessage( + I18N.invalid_amount, + $(`#${input_id} ${SELECTOR.dropzone}`), + ); + }; + + /** + * @param {string} input_id + */ + let disableActionButton = function (input_id) { + const action_button = $(`#${input_id} ${SELECTOR.dropzone} button`); + action_button.attr('disabled', true); + }; + + /** + * @param {string} input_id + */ + let enableActionButton = function (input_id) { + const action_button = $(`#${input_id} ${SELECTOR.dropzone} button`); + action_button.attr('disabled', false); + }; + + /** + * @param {string} message + * @param {jQuery} container + */ + let displayErrorMessage = function (message, container) { + container.find(SELECTOR.error_message).html(message); + container.find(SELECTOR.progress_indicator).addClass('error'); + }; + + /** + * @param {jQuery} container + */ + let removeErrorMessage = function (container) { + container.find(SELECTOR.error_message).text(''); + }; + + /** + * @param {number} bytes + * @returns {number} + */ + let bytesToMiB = function (bytes) { + return bytes / 1024 / 1024; + }; + + // ========================================== + // END helper functions + // ========================================== + + return { + removeAllFilesFromQueue, + renderFileEntry: renderFileEntryHook, + cancelFleUpload: cancelFileUpload, + init, + }; + }($)); +}($, il.UI.Input)); diff --git a/components/ILIAS/UI/resources/js/Prompt/dist/prompt.min.js b/components/ILIAS/UI/resources/js/Prompt/dist/prompt.min.js index bfbceaa00056..b99034e68380 100644 --- a/components/ILIAS/UI/resources/js/Prompt/dist/prompt.min.js +++ b/components/ILIAS/UI/resources/js/Prompt/dist/prompt.min.js @@ -12,4 +12,4 @@ * https://www.ilias.de * https://github.com/ILIAS-eLearning */ -!function(t,e){"use strict";class r{#t;#e;#r;constructor(t,e){if(this.#t=t,this.#e=document.getElementById(e),null===this.#e)throw new Error(`Could not find a Prompt for id '${e}'.`);this.#r=this.#e.getElementsByTagName("dialog").item(0)}show(t){this.load(t),this.#r.showModal()}close(){this.#r.close()}async load(t,e={}){await fetch(t,e).then(t=>t.text()).then(t=>{const e=(new this.#t).parseFromString(t,"text/html"),r=e.querySelector('section[data-section="il-prompt-state__title"]'),o=e.querySelector('section[data-section="il-prompt-state__contents"]'),n=e.querySelector('section[data-section="il-prompt-state__buttons"]'),i=e.querySelector('section[data-section="il-prompt-state__command"]'),s=e.querySelector('section[data-section="il-prompt-state__parameters"]'),c=e.querySelector("script"),a=this.#r.querySelector("span.il-prompt__title"),p=this.#r.querySelector("div.il-prompt__contents"),l=this.#r.querySelector("div.il-prompt__buttons");a.innerHTML=r.innerHTML,p.innerHTML=o.innerHTML,l.innerHTML=n.innerHTML,this.#o(p),this.#n(p),c&&this.#i(c.text);const m=[];return s.querySelectorAll("data").forEach(t=>{m[t.innerHTML.trim()]=t.getAttribute("value")}),{cmd:i.innerHTML.trim(),params:m}}).then(t=>{const e=t.cmd,{params:r}=t;"close"===e&&this.close(),"redirect"===e&&window.location.replace(r.redirect)})}#i(t){const e=this.#e.querySelector("section.il-prompt__scripts"),r=document.createElement("script");r.text=t,e.innerHTML="",e.appendChild(r)}#o(t){t.querySelectorAll("form").forEach(t=>{t.addEventListener("submit",e=>{e.preventDefault(),this.load(t.action,{method:t.method,body:new FormData(t)})})})}#n(t){t.querySelectorAll("a").forEach(t=>{const{target:e}=t;if("_blank"!==e){const e=t.href;t.addEventListener("click",()=>this.load(e)),t.removeAttribute("href")}})}}t.UI=t.UI||{},t.UI.prompt=new class{#t;#s=[];constructor(t){this.#t=t}init(t){if(void 0!==this.#s[t])throw new Error(`Prompt with id '${t}' has already been initialized.`);this.#s[t]=new r(this.#t,t)}get(t){return this.#s[t]??null}}(e)}(il,DOMParser); +!function(t,e){"use strict";function r(t,e){const r=t.createDocumentFragment();return r.append(...e),r}function n(t,e,r){t.querySelectorAll(`[${r}]`).forEach(t=>{const n=t.getAttribute(r);if(!e.has(n))throw new Error(`Element references '${n}' which does not exist.`);t.setAttribute(r,e.get(n))})}const i="c-prompt",s="data-section",o=`.${i}__title`,c=`.${`${i}__close`}`,a=`.${i}__buttons`,l=`.${i}__scripts`,h=`.${i}__contents`,u=`[${s}="title"]`,d=`[${s}="content"]`,m=`[${s}="buttons"]`;class p{instruction;redirect;title;contents;buttons;scripts;constructor(t,e,r,n,i,s){this.instruction=t,this.redirect=e,this.title=r,this.contents=n,this.buttons=i,this.scripts=s}}function g(t,e){return t.querySelector(`[${e}]`)?.getAttribute(e)??null}function E(t,e){return Array.from(t.querySelector(e)?.children??[])}class f{#t;#e;#r;#n;#i=!1;constructor(t,e,r,n){this.#t=t,this.#e=e,this.#r=r,this.#n=n,this.#n.addEventListener("submit",t=>this.#s(t)),this.#n.addEventListener("click",t=>this.#o(t))}show(){this.#n.showModal()}close(){this.#n.close()}async loadState(t,e={}){if(!this.#i)try{this.#i=!0;const r=await this.#e.load(this.#c(t.toString()),e);this.#a(r)}catch(t){this.#l(t)}finally{this.#i=!1}}#a(t){if("show"===t.instruction)this.#t.render(t.title,t.contents,t.buttons,t.scripts),this.show();else if("redirect"===t.instruction)this.#h(this.#c(t.redirect));else{if("close"!==t.instruction)throw new Error(`Could not process state instruction: '${t.instruction}'`);this.close()}}#l(t){throw this.#t.render(null,this.#r,[]),this.show(),t}#s(t){!t.defaultPrevented&&t.target instanceof this.#n.ownerDocument.defaultView.HTMLFormElement&&this.#u(t,t.target)}#u(t,e){const r=(t.submitter?.getAttribute("formmethod")??e.method).toUpperCase();if("DIALOG"===r)return;const n=this.#c(t.submitter?.getAttribute("formaction")??e.action),i=new FormData(e);if(t.preventDefault(),"GET"===r){const t=new URLSearchParams(i);return n.search=t.toString(),void this.loadState(n)}this.loadState(n,{method:r,body:i})}#o(t){!t.defaultPrevented&&t.target instanceof this.#n.ownerDocument.defaultView.HTMLAnchorElement&&this.#d(t,t.target)}#d(t,e){e.target&&"_self"!==e.target||(t.preventDefault(),this.loadState(this.#c(e.href)))}#h(t){this.#n.ownerDocument.defaultView.location.replace(t)}#c(t){return new URL(t,this.#n.ownerDocument.defaultView.location.baseURI)}}class w{#m;#p;#g;#E;#f;#w=new Set;constructor(t,e,r,n,i){this.#m=t,this.#p=e,this.#g=r,this.#E=n,this.#f=i}render(t,e,r,n=[]){this.#b(t),this.#S(e),this.#y(r),this.#A(n)}#b(t){null!==t&&""!==t.trim()&&(this.#p.textContent=t)}#S(t){this.#g.replaceChildren(...t)}#y(t){this.#E.replaceChildren(...t,this.#m)}#A(t){t.forEach(t=>{const e=function(t){const e=(new TextEncoder).encode(t);let r=e.length;if(r<=0)return 0;let n,i=r>>>0,s=0;const o=t=>e[t]|e[t+1]<<8,c=3&r;for(r>>>=2;r>0;r-=1)i=i+o(s)>>>0,n=(o(s+2)<<11^i)>>>0,i=(i<<16^n)>>>0,s+=4,i=i+(i>>>11)>>>0;switch(c){case 3:i=i+o(s)>>>0,i=(i^i<<16)>>>0,i=(i^e[s+2]<<24>>24<<18)>>>0,i=i+(i>>>11)>>>0;break;case 2:i=i+o(s)>>>0,i=(i^i<<11)>>>0,i=i+(i>>>17)>>>0;break;case 1:i=i+(e[s]<<24>>24)>>>0,i=(i^i<<10)>>>0,i=i+(i>>>1)>>>0}return i=(i^i<<3)>>>0,i=i+(i>>>5)>>>0,i=(i^i<<4)>>>0,i=i+(i>>>17)>>>0,i=(i^i<<25)>>>0,i=i+(i>>>6)>>>0,i.toString(16).padStart(8,"0")}(JSON.stringify(t.outerHTML));this.#w.has(e)||(this.#f.append(t),this.#w.add(e))})}}function b(t,e){const r=t.querySelector(e);if(!r)throw new Error(`Could not find Prompt element: ${e}`);return r}const S=new class{#$;#C=new Map;constructor(t){this.#$=t}async load(t,e={}){const r=t.href,n=this.#C.get(r);if(n&&!e.body)return n.state;const i=function(t){const e=g(t,"data-instruction");if(!e)throw new Error("State must provide instruction");return new p(e,g(t,"data-redirect-url"),function(t,e){return t.querySelector(e)?.textContent??null}(t,u),E(t,d),E(t,m),Array.from(t.querySelectorAll("script")))}(await this.#$.loadContent(t,e));return this.#C.set(r,{request:e,state:i}),i}}(new class{#L;constructor(t){this.#L=t}loadContent(t,e={}){return fetch(t.toString(),e).then(t=>t.text()).then(t=>this.#_(t)).then(t=>r(this.#L,t)).catch(e=>{throw new Error(`Could not render element(s) from '${t}': ${e.message}`)})}#M(t){const e=this.#L.createElement("script");return t.hasAttribute("type")&&e.setAttribute("type",t.getAttribute("type")),t.hasAttribute("src")&&e.setAttribute("src",t.getAttribute("src")),t.textContent.length>0&&(e.textContent=t.textContent),e}#_(t){const e=this.#L.createElement("div");return e.innerHTML=t.trim(),e.querySelectorAll("script").forEach(t=>{const e=this.#M(t);t.replaceWith(e)}),e.children}}(e)),y=new class{#L;constructor(t){this.#L=t}createContent(t){const e=t.content.cloneNode(!0),i=new Map;return e.querySelectorAll("[id]").forEach(t=>{const e=function(t=""){return`${t}${Date.now().toString(36)}_${Math.random().toString(36).substring(2)}`}("il_ui_fw_");i.set(t.id,e),t.id=e}),e.querySelectorAll("[for]").forEach(t=>{t.htmlFor=i.get(t.htmlFor)}),n(e,i,"aria-describedby"),n(e,i,"aria-labelledby"),n(e,i,"aria-controls"),n(e,i,"aria-owns"),r(this.#L,e.children)}}(e),A=new class{#U=new Map;set(t,e){if(this.#U.has(t))throw new Error(`Prompt already exists: '${t}'`);this.#U.set(t,e)}get(t){return this.#U.has(t)?this.#U.get(t):null}};t.UI=t.UI||{},t.UI.Prompt={createPrompt:t=>(A.set(t.id,function(t,e,r){return new f(new w(b(r,`${a} > ${c}`),b(r,o),b(r,h),b(r,a),b(r,l)),t,Array.from(e.createContent(b(r,":scope > template")).children),b(r,":scope > dialog"))}(S,y,t)),A.get(t.id)),get:t=>A.get(t)}}(il,document); diff --git a/components/ILIAS/UI/resources/js/Prompt/rollup.config.js b/components/ILIAS/UI/resources/js/Prompt/rollup.config.js index b2bbcc13ce48..2db37a212187 100644 --- a/components/ILIAS/UI/resources/js/Prompt/rollup.config.js +++ b/components/ILIAS/UI/resources/js/Prompt/rollup.config.js @@ -19,10 +19,19 @@ import copyright from '../../../../../../scripts/Copyright-Checker/copyright.js' import preserveCopyright from '../../../../../../scripts/Copyright-Checker/preserveCopyright.js'; export default { - input: './src/prompt.js', + input: './src/facade.js', + external: [ + 'document', + 'ilias', + ], output: { + // file: '../../../../../../public/assets/js/prompt.min.js', file: './dist/prompt.min.js', format: 'iife', + globals: { + document: 'document', + ilias: 'il', + }, banner: copyright, plugins: [ terser({ @@ -31,10 +40,5 @@ export default { }, }), ], - globals: { - il: 'il', - DOMParser: 'DOMParser', - }, }, - external: ['il', 'DOMParser'], }; diff --git a/components/ILIAS/UI/resources/js/Prompt/src/Prompt.js b/components/ILIAS/UI/resources/js/Prompt/src/Prompt.js new file mode 100644 index 000000000000..bff7d27dcd5b --- /dev/null +++ b/components/ILIAS/UI/resources/js/Prompt/src/Prompt.js @@ -0,0 +1,198 @@ +/** + * 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 * as PROMPT from './constants.js'; + +/** + * @author Thibeau Fuhrer + */ +export default class Prompt { + /** @type {PromptRenderer} */ + #promptRenderer; + + /** @type {PromptStateLoader} */ + #stateLoader; + + /** @type {HTMLElement[]} */ + #errorElements; + + /** @type {HTMLDialogElement} */ + #dialogElement; + + /** @type {boolean} */ + #isLoading = false; + + /** + * @param {PromptRenderer} promptRenderer + * @param {PromptStateLoader} stateLoader + * @param {HTMLElement[]} errorElements + * @param {HTMLDialogElement} dialogElement + */ + constructor(promptRenderer, stateLoader, errorElements, dialogElement) { + this.#promptRenderer = promptRenderer; + this.#stateLoader = stateLoader; + this.#errorElements = errorElements; + this.#dialogElement = dialogElement; + + // catch bubbling events to capture anchors and forms inside the dialog + this.#dialogElement.addEventListener('submit', (event) => this.#interceptFormSubmitEvent(event)); + this.#dialogElement.addEventListener('click', (event) => this.#interceptAnchorClickEvent(event)); + } + + /** + * Opens the Prompt with the last loaded Prompt State. + */ + show() { + this.#dialogElement.showModal(); + } + + /** + * Closes the Prompt. + */ + close() { + this.#dialogElement.close(); + } + + /** + * Loads a Prompt State from the server asynchronously and performs + * the received instruction. + * + * @param {URL|string} url + * @param {RequestInit} [request={}] + * @returns {Promise} + * @throws {Error} if state could not be loaded or handled + */ + async loadState(url, request = {}) { + if (this.#isLoading) { + return; + } + try { + this.#isLoading = true; + const state = await this.#stateLoader.load(this.#getUrl(url.toString()), request); + this.#handleState(state); + } catch (error) { + this.#handleError(error); + } finally { + this.#isLoading = false; + } + } + + /** + * @param {PromptState} state + * @throws {Error} if the state cannot be processed + */ + #handleState(state) { + if (state.instruction === PROMPT.PROMPT_STATE_INSTRUCTION_SHOW) { + this.#promptRenderer.render(state.title, state.contents, state.buttons, state.scripts); + this.show(); + } else if (state.instruction === PROMPT.PROMPT_STATE_INSTRUCTION_REDIRECT) { + this.#redirect(this.#getUrl(state.redirect)); + } else if (state.instruction === PROMPT.PROMPT_STATE_INSTRUCTION_CLOSE) { + this.close(); + } else { + throw new Error(`Could not process state instruction: '${state.instruction}'`); + } + } + + /** + * @param {Error} error + * @throws {Error} the given error + */ + #handleError(error) { + this.#promptRenderer.render(null, this.#errorElements, []); + this.show(); + throw error; + } + + /** + * @param {SubmitEvent} event + */ + #interceptFormSubmitEvent(event) { + if (!event.defaultPrevented + && event.target instanceof this.#dialogElement.ownerDocument.defaultView.HTMLFormElement + ) { + this.#submitFormInsidePrompt(event, event.target); + } + } + + /** + * @param {SubmitEvent} event + * @param {HTMLFormElement} form + */ + #submitFormInsidePrompt(event, form) { + const method = (event.submitter?.getAttribute('formmethod') ?? form.method).toUpperCase(); + + // ignore prompt actions that affect the dialog + if (method === 'DIALOG') { + return; + } + + const action = this.#getUrl(event.submitter?.getAttribute('formaction') ?? form.action); + const body = new FormData(form); + + event.preventDefault(); + + // convert body to query parameters for GET request + if (method === 'GET') { + const query = new URLSearchParams(body); + action.search = query.toString(); + this.loadState(action); + return; + } + + this.loadState(action, { method, body }); + } + + /** + * @param {PointerEvent} event + */ + #interceptAnchorClickEvent(event) { + if (!event.defaultPrevented + && event.target instanceof this.#dialogElement.ownerDocument.defaultView.HTMLAnchorElement + ) { + this.#openAnchorInsidePrompt(event, event.target); + } + } + + /** + * @param {MouseEvent} event + * @param {HTMLAnchorElement} form + */ + #openAnchorInsidePrompt(event, anchor) { + // ignore anchors that dont open in same target + if (anchor.target && anchor.target !== '_self') { + return; + } + + event.preventDefault(); + + this.loadState(this.#getUrl(anchor.href)); + } + + /** + * @param {URL} url + */ + #redirect(url) { + this.#dialogElement.ownerDocument.defaultView.location.replace(url); + } + + /** + * @param {string} rawUrl + * @returns {URL} + */ + #getUrl(rawUrl) { + return new URL(rawUrl, this.#dialogElement.ownerDocument.defaultView.location.baseURI); + } +} diff --git a/components/ILIAS/UI/resources/js/Prompt/src/PromptRegistry.js b/components/ILIAS/UI/resources/js/Prompt/src/PromptRegistry.js new file mode 100644 index 000000000000..2ec6b1fa65d9 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Prompt/src/PromptRegistry.js @@ -0,0 +1,46 @@ +/** + * 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 + */ + +/** + * @author Thibeau Fuhrer + */ +export default class PromptRegistry { + /** @type {Map} (prompt-id => Prompt) */ + #promptMap = new Map(); + + /** + * @param {string} id + * @param {Prompt} prompt + */ + set(id, prompt) { + if (this.#promptMap.has(id)) { + throw new Error(`Prompt already exists: '${id}'`); + } + this.#promptMap.set(id, prompt); + } + + /** + * @param {string} id + * @returns {Prompt|null} + */ + get(id) { + if (this.#promptMap.has(id)) { + return this.#promptMap.get(id); + } + return null; + } +} diff --git a/components/ILIAS/UI/resources/js/Prompt/src/PromptRenderer.js b/components/ILIAS/UI/resources/js/Prompt/src/PromptRenderer.js new file mode 100644 index 000000000000..299aac0457de --- /dev/null +++ b/components/ILIAS/UI/resources/js/Prompt/src/PromptRenderer.js @@ -0,0 +1,105 @@ +/** + * 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 createHashCode from '../../Core/src/createHashCode.js'; + +/** + * @author Thibeau Fuhrer + */ +export default class PromptRenderer { + /** @type {HTMLFormElement} */ + #closeDialogElement; + + /** @type {HTMLSpanElement} */ + #titleElement; + + /** @type {HTMLElement} */ + #contentElement; + + /** @type {HTMLElement} */ + #buttonsElement; + + /** @type {HTMLElement} */ + #scriptsElement; + + /** @type {Set} */ + #insertedScriptFingerprints = new Set(); + + /** + * @param {HTMLFormElement} closeDialogElement + * @param {HTMLSpanElement} titleElement + * @param {HTMLElement} contentElement + * @param {HTMLElement} buttonsElement + * @param {HTMLElement} scriptsElement + */ + constructor(closeDialogElement, titleElement, contentElement, buttonsElement, scriptsElement) { + this.#closeDialogElement = closeDialogElement; + this.#titleElement = titleElement; + this.#contentElement = contentElement; + this.#buttonsElement = buttonsElement; + this.#scriptsElement = scriptsElement; + } + + /** + * @param {string|null} title (pass null to keep current title) + * @param {HTMLElement[]} contents + * @param {HTMLButtonElement[]} buttons + * @param {HTMLScriptElement[]} [scripts=[]] + */ + render(title, contents, buttons, scripts = []) { + this.#maybeUpdateTitle(title); + this.#updateContent(contents); + this.#updateButtons(buttons); + this.#insertScriptsOnce(scripts); + } + + /** + * @param {string|null} title + */ + #maybeUpdateTitle(title) { + // ensure title is only updated if provided + if (title !== null && title.trim() !== '') { + this.#titleElement.textContent = title; + } + } + + /** + * @param {HTMLElement[]} contents + */ + #updateContent(contents) { + this.#contentElement.replaceChildren(...contents); + } + + /** + * @param {HTMLButtonElement[]} buttons + */ + #updateButtons(buttons) { + // ensure the close action stays in last place + this.#buttonsElement.replaceChildren(...buttons, this.#closeDialogElement); + } + + /** + * @param {HTMLScriptElement[]} scripts + */ + #insertScriptsOnce(scripts) { + scripts.forEach((script) => { + const fingerprint = createHashCode(JSON.stringify(script.outerHTML)); + if (!this.#insertedScriptFingerprints.has(fingerprint)) { + this.#scriptsElement.append(script); + this.#insertedScriptFingerprints.add(fingerprint); + } + }); + } +} diff --git a/components/ILIAS/UI/resources/js/Prompt/src/PromptState.js b/components/ILIAS/UI/resources/js/Prompt/src/PromptState.js new file mode 100644 index 000000000000..157004bf4ddc --- /dev/null +++ b/components/ILIAS/UI/resources/js/Prompt/src/PromptState.js @@ -0,0 +1,54 @@ +/** + * 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 PromptState { + /** @type {string} */ + instruction; + + /** @type {string|null} */ + redirect; + + /** @type {string|null} */ + title; + + /** @type {HTMLElement[]} */ + contents; + + /** @type {HTMLElement[]} */ + buttons; + + /** @type {HTMLScriptElement[]} */ + scripts; + + /** + * @param {string} instruction + * @param {string} redirect + * @param {string|null} title + * @param {HTMLElement[]} contents + * @param {HTMLButtonElement[]} buttons + * @param {HTMLScriptElement[]} scripts + */ + constructor(instruction, redirect, title, contents, buttons, scripts) { + this.instruction = instruction; + this.redirect = redirect; + this.title = title; + this.contents = contents; + this.buttons = buttons; + this.scripts = scripts; + } +} diff --git a/components/ILIAS/UI/resources/js/Prompt/src/PromptStateLoader.js b/components/ILIAS/UI/resources/js/Prompt/src/PromptStateLoader.js new file mode 100644 index 000000000000..5d43e734a4bb --- /dev/null +++ b/components/ILIAS/UI/resources/js/Prompt/src/PromptStateLoader.js @@ -0,0 +1,56 @@ +/** + * 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 createPromptState from './createPromptState.js'; + +/** + * @author Thibeau Fuhrer + */ +export default class PromptStateLoader { + /** @type {AsyncRenderer} */ + #asyncRenderer; + + /** @type {Map} (url => {...}) */ + #cache = new Map(); + + /** + * @param {AsyncRenderer} asyncRenderer + */ + constructor(asyncRenderer) { + this.#asyncRenderer = asyncRenderer; + } + + /** + * @param {URL} url + * @param {RequestInit} request + * @returns {Promise} + * @throws {Error} if state could not be fetched + */ + async load(endpoint, request = {}) { + const cacheKey = endpoint.href; + const cacheHit = this.#cache.get(cacheKey); + + // only return cache hit if the request is GET + if (cacheHit && !request.body) { + return cacheHit.state; + } + + const rawState = await this.#asyncRenderer.loadContent(endpoint, request); + const state = createPromptState(rawState); + this.#cache.set(cacheKey, { request, state }); + + return state; + } +} diff --git a/components/ILIAS/UI/resources/js/Prompt/src/constants.js b/components/ILIAS/UI/resources/js/Prompt/src/constants.js new file mode 100644 index 000000000000..d907bf7d5f0b --- /dev/null +++ b/components/ILIAS/UI/resources/js/Prompt/src/constants.js @@ -0,0 +1,45 @@ +/** + * 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 const PROMPT_CLASS = 'c-prompt'; +export const PROMPT_STATE_CLASS = `${PROMPT_CLASS}-state`; +export const PROMPT_CLOSE_CLASS = `${PROMPT_CLASS}__close`; + +export const PROMPT_STATE_INSTRUCTION_ATTRIBUTE = 'data-instruction'; +export const PROMPT_STATE_REDIRECT_ATTRIBUTE = 'data-redirect-url'; +export const PROMPT_STATE_SECTION_ATTRIBUTE = 'data-section'; + +export const PROMPT_STATE_INSTRUCTION_SHOW = 'show'; +export const PROMPT_STATE_INSTRUCTION_CLOSE = 'close'; +export const PROMPT_STATE_INSTRUCTION_REDIRECT = 'redirect'; + +export const PROMPT_STATE_SECTION_TITLE = 'title'; +export const PROMPT_STATE_SECTION_CONTENT = 'content'; +export const PROMPT_STATE_SECTION_BUTTONS = 'buttons'; + +export const PROMPT_SELECTOR = `.${PROMPT_CLASS}`; +export const PROMPT_TITLE_SELECTOR = `.${PROMPT_CLASS}__title`; +export const PROMPT_CLOSE_SELECTOR = `.${PROMPT_CLOSE_CLASS}`; +export const PROMPT_BUTTONS_SELECTOR = `.${PROMPT_CLASS}__buttons`; +export const PROMPT_SCRIPTS_SELECTOR = `.${PROMPT_CLASS}__scripts`; +export const PROMPT_CONTENT_SELECTOR = `.${PROMPT_CLASS}__contents`; +export const PROMPT_STATE_SELECTOR = `.${PROMPT_STATE_CLASS}`; +export const PROMPT_STATE_REDIRECT_SELECTOR = `[${PROMPT_STATE_REDIRECT_ATTRIBUTE}]`; +export const PROMPT_STATE_INSTRUCTION_SELECTOR = `[${PROMPT_STATE_INSTRUCTION_ATTRIBUTE}]`; +export const PROMPT_STATE_SECTION_TITLE_SELECTOR = `[${PROMPT_STATE_SECTION_ATTRIBUTE}="${PROMPT_STATE_SECTION_TITLE}"]`; +export const PROMPT_STATE_SECTION_CONTENT_SELECTOR = `[${PROMPT_STATE_SECTION_ATTRIBUTE}="${PROMPT_STATE_SECTION_CONTENT}"]`; +export const PROMPT_STATE_SECTION_BUTTONS_SELECTOR = `[${PROMPT_STATE_SECTION_ATTRIBUTE}="${PROMPT_STATE_SECTION_BUTTONS}"]`; diff --git a/components/ILIAS/UI/resources/js/Prompt/src/createPrompt.js b/components/ILIAS/UI/resources/js/Prompt/src/createPrompt.js new file mode 100644 index 000000000000..8729fc2fe41b --- /dev/null +++ b/components/ILIAS/UI/resources/js/Prompt/src/createPrompt.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 + */ + +import * as PROMPT from './constants.js'; +import Prompt from './Prompt.js'; +import PromptRenderer from './PromptRenderer.js'; + +/** + * @param {HTMLElement} element + * @param {string} selector + * @returns {HTMLElement} + */ +function getElementOrAbort(element, selector) { + const foundElement = element.querySelector(selector); + if (!foundElement) { + throw new Error(`Could not find Prompt element: ${selector}`); + } + return foundElement; +} + +/** + * @param {PromptStateLoader} promptStateLoader + * @param {TemplateRenderer} templateRenderer + * @param {HTMLElement} element + */ +export default function createPrompt(promptStateLoader, templateRenderer, element) { + return new Prompt( + new PromptRenderer( + getElementOrAbort(element, `${PROMPT.PROMPT_BUTTONS_SELECTOR} > ${PROMPT.PROMPT_CLOSE_SELECTOR}`), + getElementOrAbort(element, PROMPT.PROMPT_TITLE_SELECTOR), + getElementOrAbort(element, PROMPT.PROMPT_CONTENT_SELECTOR), + getElementOrAbort(element, PROMPT.PROMPT_BUTTONS_SELECTOR), + getElementOrAbort(element, PROMPT.PROMPT_SCRIPTS_SELECTOR), + ), + promptStateLoader, + Array.from(templateRenderer.createContent(getElementOrAbort(element, ':scope > template')).children), + getElementOrAbort(element, ':scope > dialog'), + ); +} diff --git a/components/ILIAS/UI/resources/js/Prompt/src/createPromptState.js b/components/ILIAS/UI/resources/js/Prompt/src/createPromptState.js new file mode 100644 index 000000000000..da16169db604 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Prompt/src/createPromptState.js @@ -0,0 +1,65 @@ +/** + * 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 * as PROMPT from './constants.js'; +import PromptState from './PromptState.js'; + +/** + * @param {string} selector + * @param {DocumentFragment} fragment + * @returns {string|null} + */ +function extractElementText(fragment, selector) { + return fragment.querySelector(selector)?.textContent ?? null; +} + +/** + * @param {string} attribute + * @param {DocumentFragment} fragment + * @returns {string|null} + */ +function extractAttribute(fragment, attribute) { + return fragment.querySelector(`[${attribute}]`)?.getAttribute(attribute) ?? null; +} + +/** + * @param {string} selector + * @param {DocumentFragment} fragment + * @returns {HTMLElement[]} + */ +function extractElementChildren(fragment, selector) { + return Array.from(fragment.querySelector(selector)?.children ?? []); +} + +/** + * Creates a Prompt State from its raw format (as DocumentFragment). + * + * @param {DocumentFragment} fragment + * @returns {PromptState} + */ +export default function createPromptState(fragment) { + const instruction = extractAttribute(fragment, PROMPT.PROMPT_STATE_INSTRUCTION_ATTRIBUTE); + if (!instruction) { + throw new Error('State must provide instruction'); + } + return new PromptState( + instruction, + extractAttribute(fragment, PROMPT.PROMPT_STATE_REDIRECT_ATTRIBUTE), + extractElementText(fragment, PROMPT.PROMPT_STATE_SECTION_TITLE_SELECTOR), + extractElementChildren(fragment, PROMPT.PROMPT_STATE_SECTION_CONTENT_SELECTOR), + extractElementChildren(fragment, PROMPT.PROMPT_STATE_SECTION_BUTTONS_SELECTOR), + Array.from(fragment.querySelectorAll('script')), + ); +} diff --git a/components/ILIAS/UI/resources/js/Prompt/src/facade.js b/components/ILIAS/UI/resources/js/Prompt/src/facade.js new file mode 100644 index 000000000000..aa63695d56c9 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Prompt/src/facade.js @@ -0,0 +1,36 @@ +/** + * 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 AsyncRenderer from '../../Core/src/AsyncRenderer.js'; +import TemplateRenderer from '../../Core/src/TemplateRenderer.js'; +import PromptRegistry from './PromptRegistry.js'; +import PromptStateLoader from './PromptStateLoader.js'; +import createPrompt from './createPrompt.js'; + +const promptStateLoader = new PromptStateLoader(new AsyncRenderer(document)); +const templateRenderer = new TemplateRenderer(document); +const promptRegistry = new PromptRegistry(); + +il.UI = il.UI || {}; + +il.UI.Prompt = { + createPrompt: (element) => { + promptRegistry.set(element.id, createPrompt(promptStateLoader, templateRenderer, element)); + return promptRegistry.get(element.id); + }, + get: (id) => promptRegistry.get(id), +}; diff --git a/components/ILIAS/UI/resources/js/Prompt/src/prompt.class.js b/components/ILIAS/UI/resources/js/Prompt/src/prompt.class.js deleted file mode 100644 index 11d345223ec8..000000000000 --- a/components/ILIAS/UI/resources/js/Prompt/src/prompt.class.js +++ /dev/null @@ -1,161 +0,0 @@ -/** - * 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 - */ - -export default class Prompt { - /** - * @type {DOMParser} - */ - #DOMParser; - - /** - * @type {HTMLDivElement} - */ - #component; - - /** - * @type {HTMLPromptElement} - */ - #prompt; - - /** - * @param {DOMParser} DOMParser - * @param {string} componentId - * @throws {Error} if DOM element is missing - */ - constructor(DOMParser, componentId) { - this.#DOMParser = DOMParser; - this.#component = document.getElementById(componentId); - if (this.#component === null) { - throw new Error(`Could not find a Prompt for id '${componentId}'.`); - } - this.#prompt = this.#component.getElementsByTagName('dialog').item(0); - } - - /** - * @param {string} url - * @return {void} - */ - show(url) { - this.load(url); - this.#prompt.showModal(); - } - - /** - * @return {void} - */ - close() { - this.#prompt.close(); - } - - async load(url, par = {}) { - await fetch(url, par) - .then((resp) => resp.text()) - .then((html) => { - const parser = new this.#DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const title = doc.querySelector('section[data-section="il-prompt-state__title"]'); - const contents = doc.querySelector('section[data-section="il-prompt-state__contents"]'); - const buttons = doc.querySelector('section[data-section="il-prompt-state__buttons"]'); - const command = doc.querySelector('section[data-section="il-prompt-state__command"]'); - const parameters = doc.querySelector('section[data-section="il-prompt-state__parameters"]'); - const scripts = doc.querySelector('script'); - const dialogTitle = this.#prompt.querySelector('span.il-prompt__title'); - const dialogContents = this.#prompt.querySelector('div.il-prompt__contents'); - const dialogButtons = this.#prompt.querySelector('div.il-prompt__buttons'); - dialogTitle.innerHTML = title.innerHTML; - dialogContents.innerHTML = contents.innerHTML; - dialogButtons.innerHTML = buttons.innerHTML; - - this.#captureForms(dialogContents); - this.#captureLinks(dialogContents); - if (scripts) { - this.#appendScript(scripts.text); - } - - const params = []; - parameters.querySelectorAll('data').forEach( - (data) => { - params[data.innerHTML.trim()] = data.getAttribute('value'); - }, - ); - - return { - cmd: command.innerHTML.trim(), - params, - }; - }) - .then( - (script) => { - const command = script.cmd; - const { params } = script; - if (command === 'close') { - this.close(); - } - if (command === 'redirect') { - window.location.replace(params.redirect); - } - }, - ); - } - - /** - * @param {string} js - * @return {void} - */ - #appendScript(js) { - const dialogScript = this.#component.querySelector('section.il-prompt__scripts'); - const script = document.createElement('script'); - script.text = js; - dialogScript.innerHTML = ''; - dialogScript.appendChild(script); - } - - /** - * @param {HTMLDivElement} doc - * @return {void} - */ - #captureForms(doc) { - const forms = doc.querySelectorAll('form'); - forms.forEach( - (form) => { - form.addEventListener('submit', (e) => { - e.preventDefault(); - this.load(form.action, { - method: form.method, - body: new FormData(form), - }); - }); - }, - ); - } - - /** - * @param {HTMLDivElement} doc - * @return {void} - */ - #captureLinks(doc) { - const links = doc.querySelectorAll('a'); - links.forEach( - (lnk) => { - const { target } = lnk; - if (target !== '_blank') { - const url = lnk.href; - lnk.addEventListener('click', () => this.load(url)); - lnk.removeAttribute('href'); - } - }, - ); - } -} diff --git a/components/ILIAS/UI/resources/js/Prompt/src/prompt.factory.js b/components/ILIAS/UI/resources/js/Prompt/src/prompt.factory.js deleted file mode 100644 index e8daa037cdc4..000000000000 --- a/components/ILIAS/UI/resources/js/Prompt/src/prompt.factory.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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 Prompt from './prompt.class'; - -export default class PromptFactory { - /** - * @type {DOMParser} - */ - #DOMParser; - - /** - * @type {Array} - */ - #instances = []; - - /** - * @param {DOMParser} DOMParser - */ - constructor(DOMParser) { - this.#DOMParser = DOMParser; - } - - /** - * @param {string} id - * @return {void} - * @throws {Error} if the prompt was already initialized. - */ - init(id) { - if (this.#instances[id] !== undefined) { - throw new Error(`Prompt with id '${id}' has already been initialized.`); - } - - this.#instances[id] = new Prompt(this.#DOMParser, id); - } - - /** - * @param {string} id - * @return {Prompt|null} - */ - get(id) { - return this.#instances[id] ?? null; - } -} diff --git a/components/ILIAS/UI/src/Component/Entity/Entity.php b/components/ILIAS/UI/src/Component/Entity/Entity.php index 1ed2beaef8d5..818893344185 100755 --- a/components/ILIAS/UI/src/Component/Entity/Entity.php +++ b/components/ILIAS/UI/src/Component/Entity/Entity.php @@ -35,6 +35,11 @@ */ interface Entity extends Component { + /** + * Returns the technical identifier of this entity. + */ + public function getId(): string|int; + //Priority Areas /** diff --git a/components/ILIAS/UI/src/Component/Entity/EntityRetrieval.php b/components/ILIAS/UI/src/Component/Entity/EntityRetrieval.php new file mode 100644 index 000000000000..d58e399eb181 --- /dev/null +++ b/components/ILIAS/UI/src/Component/Entity/EntityRetrieval.php @@ -0,0 +1,64 @@ + + */ + public function getEntities( + \ILIAS\UI\Factory $ui_factory, + Range $range, + Order $order, + mixed $additional_viewcontrol_data, + mixed $filter_data, + mixed $additional_parameters, + ): Generator; + + /** + * This method is used by the Entity Listing to visualise a subset of entities. + * + * If an Entity does not exist anymore it MUST return a pseudo entity visually + * marked as such. + * + * @param array $entity_ids + * @return Generator + */ + public function getEntitiesByIds( + \ILIAS\UI\Factory $ui_factory, + Order $order, + array $entity_ids, + ): Generator; +} diff --git a/components/ILIAS/UI/src/Component/Entity/Factory.php b/components/ILIAS/UI/src/Component/Entity/Factory.php index a85d2e8135f6..37c4d9a42905 100755 --- a/components/ILIAS/UI/src/Component/Entity/Factory.php +++ b/components/ILIAS/UI/src/Component/Entity/Factory.php @@ -37,10 +37,14 @@ interface Factory * The Standard Entity can (and should) be used to list system entities * such as repository objects, users and similar. * --- + * @param string|int $technical_identifier + * @param Symbol|Image|ShyButton|ShyLink|string $primary_visual_identifier + * @param Symbol|Image|ShyButton|ShyLink|string $secondary_visual_identifier * @return \ILIAS\UI\Component\Entity\Standard */ public function standard( - Symbol | Image | ShyButton | ShyLink | string $primary_identifier, - Symbol | Image | ShyButton | ShyLink | string $secondary_identifier + string|int $technical_identifier, + Symbol|Image|ShyButton|ShyLink|string $primary_visual_identifier, + Symbol|Image|ShyButton|ShyLink|string $secondary_visual_identifier, ): Standard; } diff --git a/components/ILIAS/UI/src/Component/Input/Field/Factory.php b/components/ILIAS/UI/src/Component/Input/Field/Factory.php index 3c9c4294d376..77eab5f5ae5a 100755 --- a/components/ILIAS/UI/src/Component/Input/Field/Factory.php +++ b/components/ILIAS/UI/src/Component/Input/Field/Factory.php @@ -790,25 +790,58 @@ public function link(string $label, ?string $byline = null): Link; * --- * description: * purpose: > - * Hidden inputs are used for transmitting persistent data which the user should not manipulate. + * The Hidden Input is used for transmitting data associated to between server roundtrips. + * Users cannot see or operate this Input. * composition: > - * Hidden inputs consist of a html-input type hidden. + * The Hidden Input consists of one HTML input type hidden. * effect: > - * A hidden input is rendered where developers can set any kind of value that should be transmitted. + * Hidden Input's cannot be interacted with and do not have any effect. + * They only carry data between requests. + * rivals: + * Hidden List: use a Hidden List Input if your data is a list. * * context: - * - The Hidden input is used in UI-forms. + * - The Hidden Input is used in Input Form Container's. * * rules: * usage: - * 1: > - * Use this input for persistent data which the user should not manipulate. - * + * 1: > + * This Input SHOULD be used for persistent data which the user SHOULD NOT see. + * 2: > + * You MUST NOT trust the received data, since clients can be tampered with. * --- * @return \ILIAS\UI\Component\Input\Field\Hidden */ public function hidden(): Hidden; + /** + * --- + * description: + * purpose: > + * The Hidden List Input is used for transmitting a list of data between server roundtrips. + * Users cannot see or operate this Input. + * composition: > + * The Hidden List Input consists of multiple HTML input type hidden. + * effect: > + * Hidden List Input's cannot be interacted with and do not have any effect. + * They only carry data between requests. + * rivals: + * Hidden: use a Hidden Input if your data is not a list. + * + * context: + * - The Hidden List Input is used in Input Form Container's. + * + * rules: + * usage: + * 1: > + * This Input SHOULD be used for persistent data which the user SHOULD NOT see. + * 2: > + * You MUST NOT trust the received data, since clients can be tampered with. + * --- + * @return \ILIAS\UI\Component\Input\Field\HiddenList + */ + public function hiddenList(): HiddenList; + /** * --- * description: diff --git a/components/ILIAS/UI/src/Implementation/Component/Prompt/Standard.php b/components/ILIAS/UI/src/Component/Input/Field/HiddenList.php similarity index 71% rename from components/ILIAS/UI/src/Implementation/Component/Prompt/Standard.php rename to components/ILIAS/UI/src/Component/Input/Field/HiddenList.php index 83cf756c98a0..947cbeadd5b0 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/Standard.php +++ b/components/ILIAS/UI/src/Component/Input/Field/HiddenList.php @@ -13,16 +13,15 @@ * us at: * https://www.ilias.de * https://github.com/ILIAS-eLearning - * - *********************************************************************/ + */ -declare(strict_types=1); +namespace ILIAS\UI\Component\Input\Field; -namespace ILIAS\UI\Implementation\Component\Prompt; +use ILIAS\UI\Component\Input\Container\Form\FormInput; /** - * + * @author Thibeau Fuhrer */ -class Standard extends Prompt +interface HiddenList extends FormInput { } diff --git a/components/ILIAS/UI/src/Component/Listing/Entity/DataRetrieval.php b/components/ILIAS/UI/src/Component/Listing/Entity/DataRetrieval.php deleted file mode 100755 index ff9f49f40189..000000000000 --- a/components/ILIAS/UI/src/Component/Listing/Entity/DataRetrieval.php +++ /dev/null @@ -1,40 +0,0 @@ - $additional_parameters - * @return \Generator - */ - public function getEntities( - Mapping $mapping, - ?Range $range, - ?array $additional_parameters - ): \Generator; -} diff --git a/components/ILIAS/UI/src/Component/Listing/Entity/EntityListing.php b/components/ILIAS/UI/src/Component/Listing/Entity/Entity.php old mode 100755 new mode 100644 similarity index 56% rename from components/ILIAS/UI/src/Component/Listing/Entity/EntityListing.php rename to components/ILIAS/UI/src/Component/Listing/Entity/Entity.php index 6f5c98022c95..51a8355ccfc7 --- a/components/ILIAS/UI/src/Component/Listing/Entity/EntityListing.php +++ b/components/ILIAS/UI/src/Component/Listing/Entity/Entity.php @@ -21,16 +21,21 @@ namespace ILIAS\UI\Component\Listing\Entity; use ILIAS\UI\Component\Component; +use ILIAS\UI\Component\Entity\EntityRetrieval; /** - * This is what an EntityListings looks like + * This is what an Entity Listing looks like. */ -interface EntityListing extends Component +interface Entity extends Component { /** - * An Entity Listing is constructed with an instance of RecordToEntity, - * which is the mapping of a single record to an Entity. - * Data retrieval will accumulate/consolidate the records. + * Get an Entity Listing like this, but provide it with a subset of entities. + * If no subset is provided, the whole set will be rendered instead. + * + * This will invoke {@see EntityRetrieval::getEntitiesByIds()} instead of + * {@see EntityRetrieval::getEntities()}. + * + * @param array $entity_ids */ - public function withData(DataRetrieval $data): self; + public function withSubset(array $entity_ids): static; } diff --git a/components/ILIAS/UI/src/Component/Listing/Entity/Factory.php b/components/ILIAS/UI/src/Component/Listing/Entity/Factory.php index ce6f9930ebfa..23473945eaac 100755 --- a/components/ILIAS/UI/src/Component/Listing/Entity/Factory.php +++ b/components/ILIAS/UI/src/Component/Listing/Entity/Factory.php @@ -20,6 +20,8 @@ namespace ILIAS\UI\Component\Listing\Entity; +use ILIAS\UI\Component\Entity\EntityRetrieval; + /** * This is what a factory for EntityListings looks like */ @@ -36,10 +38,10 @@ interface Factory * the space optimally. The design of the entity is one that favors a more horizontal representation. * * --- - * @param \ILIAS\UI\Component\Listing\Entity\RecordToEntity $entity_mapping + * @param \ILIAS\UI\Component\Entity\EntityRetrieval $entity_retrieval * @return \ILIAS\UI\Component\Listing\Entity\Standard */ - public function standard(RecordToEntity $entity_mapping): Standard; + public function standard(EntityRetrieval $entity_retrieval): Standard; /** * --- @@ -57,8 +59,8 @@ public function standard(RecordToEntity $entity_mapping): Standard; * with the same height. * * --- - * @param \ILIAS\UI\Component\Listing\Entity\RecordToEntity $entity_mapping + * @param \ILIAS\UI\Component\Entity\EntityRetrieval $entity_retrieval * @return \ILIAS\UI\Component\Listing\Entity\Grid */ - public function grid(RecordToEntity $entity_mapping): Grid; + public function grid(EntityRetrieval $entity_retrieval): Grid; } diff --git a/components/ILIAS/UI/src/Component/Listing/Entity/Grid.php b/components/ILIAS/UI/src/Component/Listing/Entity/Grid.php index b9e060d93529..0f7312a9b0cb 100644 --- a/components/ILIAS/UI/src/Component/Listing/Entity/Grid.php +++ b/components/ILIAS/UI/src/Component/Listing/Entity/Grid.php @@ -20,6 +20,6 @@ namespace ILIAS\UI\Component\Listing\Entity; -interface Grid extends EntityListing +interface Grid extends Entity { } diff --git a/components/ILIAS/UI/src/Component/Listing/Entity/Mapping.php b/components/ILIAS/UI/src/Component/Listing/Entity/Mapping.php deleted file mode 100755 index ea0f31764cf3..000000000000 --- a/components/ILIAS/UI/src/Component/Listing/Entity/Mapping.php +++ /dev/null @@ -1,31 +0,0 @@ - $options (key => value) + */ + public function withOptions(array $options): static; + /** * Get the options of this signal + * @return array (key => value) */ public function getOptions(): array; } diff --git a/components/ILIAS/UI/src/Implementation/Component/Button/ButtonRendererFactory.php b/components/ILIAS/UI/src/Implementation/Component/Button/ButtonRendererFactory.php index 27517c12982b..1d646cf0a036 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Button/ButtonRendererFactory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Button/ButtonRendererFactory.php @@ -44,7 +44,7 @@ class ButtonRendererFactory extends DefaultRendererFactory public function getRendererInContext(Component $component, array $contexts): ComponentRenderer { - if ($this->isIndirectInputContainerContext($contexts)) { + if ($this->context_matcher->isIndirectDescendantOfAny($contexts, self::USE_FORM_CONTEXT_RENDERER_FOR_INDIRECT_DESCENDANTS_OF)) { return new IndirectInputContainerContextRenderer( $this->ui_factory, $this->tpl_factory, @@ -58,24 +58,4 @@ public function getRendererInContext(Component $component, array $contexts): Com } return parent::getRendererInContext($component, $contexts); } - - /** - * @param string[] $contexts_asc canonical names (first to last) - */ - protected function isIndirectInputContainerContext(array $contexts_asc): bool - { - // ensure minimum context size allowing indirect descendants - $context_size = count($contexts_asc); - if (3 > $context_size) { - return false; - } - // check if button is direct descendant - $direct_ancestor = $contexts_asc[$context_size - 2]; - if (in_array($direct_ancestor, self::USE_FORM_CONTEXT_RENDERER_FOR_INDIRECT_DESCENDANTS_OF, true)) { - return false; - } - // check if button is indirect descendant - $indirect_ancestors = array_splice($contexts_asc, 0, -2); - return (0 < count(array_intersect($indirect_ancestors, self::USE_FORM_CONTEXT_RENDERER_FOR_INDIRECT_DESCENDANTS_OF))); - } } diff --git a/components/ILIAS/UI/src/Implementation/Component/Entity/Entity.php b/components/ILIAS/UI/src/Implementation/Component/Entity/Entity.php index 04ae7352e2b2..3f7d99a6017a 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Entity/Entity.php +++ b/components/ILIAS/UI/src/Implementation/Component/Entity/Entity.php @@ -77,11 +77,17 @@ abstract class Entity implements I\Entity protected ?Workflow\Linear $workflow = null; public function __construct( + protected string|int $id, protected Symbol | Image | Shy | StandardLink | string $primary_identifier, protected Symbol | Image | Shy | StandardLink | string $secondary_identifier ) { } + public function getId(): string|int + { + return $this->id; + } + public function withPrimaryIdentifier(Symbol | Image | Shy | StandardLink | string $primary_identifier): self { $clone = clone $this; diff --git a/components/ILIAS/UI/src/Implementation/Component/Entity/EntityRendererFactory.php b/components/ILIAS/UI/src/Implementation/Component/Entity/EntityRendererFactory.php new file mode 100644 index 000000000000..b107973dec0b --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Component/Entity/EntityRendererFactory.php @@ -0,0 +1,48 @@ +context_matcher->isDirectDescendantOf($contexts, self::PROMPT_STATE_ENTITY_LISTING_CONTEXT)) { + return new PromptStateEntityListingContextRenderer( + $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/Entity/Factory.php b/components/ILIAS/UI/src/Implementation/Component/Entity/Factory.php index 46ff666c23d8..363f4d5b6860 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Entity/Factory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Entity/Factory.php @@ -29,9 +29,10 @@ class Factory implements I\Factory { public function standard( - Symbol | Image | ShyButton | ShyLink | string $primary_identifier, - Symbol | Image | ShyButton | ShyLink | string $secondary_identifier + string|int $technical_identifier, + Symbol|Image|ShyButton|ShyLink|string $primary_visual_identifier, + Symbol|Image|ShyButton|ShyLink|string $secondary_visual_identifier, ): Standard { - return new Standard($primary_identifier, $secondary_identifier); + return new Standard($technical_identifier, $primary_visual_identifier, $secondary_visual_identifier); } } diff --git a/components/ILIAS/UI/src/Implementation/Component/Entity/PromptStateEntityListingContextRenderer.php b/components/ILIAS/UI/src/Implementation/Component/Entity/PromptStateEntityListingContextRenderer.php new file mode 100644 index 000000000000..6e5d7543a184 --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Component/Entity/PromptStateEntityListingContextRenderer.php @@ -0,0 +1,43 @@ +getTemplate('tpl.entity.html', true, true); + + $this->renderPrimaryIdentifier($component, $default_renderer, $tpl); + $this->renderSecondaryIdentifier($component, $default_renderer, $tpl); + $this->renderMainDetails($component, $default_renderer, $tpl); + + return $tpl->get(); + } +} diff --git a/components/ILIAS/UI/src/Implementation/Component/Entity/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Entity/Renderer.php index 3bb4ff664005..8cf51b999518 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Entity/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Entity/Renderer.php @@ -23,6 +23,7 @@ use ILIAS\UI\Implementation\Render\AbstractComponentRenderer; use ILIAS\UI\Renderer as RendererInterface; use ILIAS\UI\Component; +use ILIAS\UI\Implementation\Render\Template; class Renderer extends AbstractComponentRenderer { @@ -40,8 +41,53 @@ public function render(Component\Component $component, RendererInterface $defaul protected function renderEntity(Entity $component, RendererInterface $default_renderer): string { $tpl = $this->getTemplate('tpl.entity.html', true, true); - $secondary_identifier = $component->getSecondaryIdentifier(); + $this->renderPrimaryIdentifier($component, $default_renderer, $tpl); + $this->renderSecondaryIdentifier($component, $default_renderer, $tpl); + $this->renderBlockingConditions($component, $default_renderer, $tpl); + $this->renderFeatures($component, $default_renderer, $tpl); + $this->renderPersonalStatus($component, $default_renderer, $tpl); + $this->renderMainDetails($component, $default_renderer, $tpl); + $this->renderAvailability($component, $default_renderer, $tpl); + $this->renderDetails($component, $default_renderer, $tpl); + + if (null !== $component->getWorkflow()) { + $button_components = $this->createUnfinishedWorkflowActions($component->getWorkflow()); + $tpl->setVariable('WORKFLOW_ACTIONS', $default_renderer->render($button_components)); + } + if ($actions = $component->getManagingActions()) { + $actions_dropdown = $this->getUIFactory()->dropdown()->standard($actions); + $tpl->setVariable('MANAGING_ACTIONS', $default_renderer->render($actions_dropdown)); + } + if ($reactions = $component->getReactions()) { + $tpl->setVariable('REACTIONS', $default_renderer->render($reactions)); + } + if ($prio_reactions = $component->getPrioritizedReactions()) { + $tpl->setVariable('PRIO_REACTIONS', $default_renderer->render($prio_reactions)); + } + + return $tpl->get(); + } + + protected function renderPrimaryIdentifier( + Entity $component, + RendererInterface $default_renderer, + Template $tpl + ): void { + $primary_identifier = $component->getPrimaryIdentifier(); + if (!is_string($primary_identifier)) { + $primary_identifier = $this->maybeRender($default_renderer, $primary_identifier); + } + $tpl->setVariable('PRIMARY_IDENTIFIER', $primary_identifier); + $tpl->setVariable('PRIMARY_IDENTIFIER_ID', $this->createId()); + } + + protected function renderSecondaryIdentifier( + Entity $component, + RendererInterface $default_renderer, + Template $tpl + ): void { + $secondary_identifier = $component->getSecondaryIdentifier(); if (is_string($secondary_identifier)) { $tpl->touchBlock('secondid_string'); } elseif ($secondary_identifier instanceof Component\Image\Image) { @@ -53,37 +99,40 @@ protected function renderEntity(Entity $component, RendererInterface $default_re } elseif ($secondary_identifier instanceof Component\Button\Shy) { $tpl->touchBlock('secondid_shy'); } + if (!is_string($secondary_identifier)) { + $secondary_identifier = $this->maybeRender($default_renderer, $secondary_identifier); + } + $tpl->setVariable('SECONDARY_IDENTIFIER', $secondary_identifier); + } - $tpl->setVariable('SECONDARY_IDENTIFIER', is_string($secondary_identifier) ? $secondary_identifier : $this->maybeRender($default_renderer, $secondary_identifier)); - - $primary_identifier = $component->getPrimaryIdentifier(); - $primary_identifier = is_string($primary_identifier) ? $primary_identifier : $this->maybeRender($default_renderer, $primary_identifier); - $tpl->setVariable('PRIMARY_IDENTIFIER', $primary_identifier); - $tpl->setVariable('PRIMARY_IDENTIFIER_ID', $this->createId()); - + protected function renderBlockingConditions(Entity $component, RendererInterface $default_renderer, Template $tpl): void + { $tpl->setVariable('BLOCKING_CONDITIONS', $this->maybeRender($default_renderer, ...$component->getBlockingAvailabilityConditions())); + } + + protected function renderFeatures(Entity $component, RendererInterface $default_renderer, Template $tpl): void + { $tpl->setVariable('FEATURES', $this->maybeRender($default_renderer, ...$component->getFeaturedProperties())); + } + + protected function renderPersonalStatus(Entity $component, RendererInterface $default_renderer, Template $tpl): void + { $tpl->setVariable('PERSONAL_STATUS', $this->maybeRender($default_renderer, ...$component->getPersonalStatus())); + } + + protected function renderMainDetails(Entity $component, RendererInterface $default_renderer, Template $tpl): void + { $tpl->setVariable('MAIN_DETAILS', $this->maybeRender($default_renderer, ...$component->getMainDetails())); - $tpl->setVariable('AVAILABILITY', $this->maybeRender($default_renderer, ...$component->getAvailability())); - $tpl->setVariable('DETAILS', $this->maybeRender($default_renderer, ...$component->getDetails())); + } - if (null !== $component->getWorkflow()) { - $button_components = $this->createUnfinishedWorkflowActions($component->getWorkflow()); - $tpl->setVariable('WORKFLOW_ACTIONS', $default_renderer->render($button_components)); - } - if ($actions = $component->getManagingActions()) { - $actions_dropdown = $this->getUIFactory()->dropdown()->standard($actions); - $tpl->setVariable('MANAGING_ACTIONS', $default_renderer->render($actions_dropdown)); - } - if ($reactions = $component->getReactions()) { - $tpl->setVariable('REACTIONS', $default_renderer->render($reactions)); - } - if ($prio_reactions = $component->getPrioritizedReactions()) { - $tpl->setVariable('PRIO_REACTIONS', $default_renderer->render($prio_reactions)); - } + protected function renderAvailability(Entity $component, RendererInterface $default_renderer, Template $tpl): void + { + $tpl->setVariable('AVAILABILITY', $this->maybeRender($default_renderer, ...$component->getAvailability())); + } - return $tpl->get(); + protected function renderDetails(Entity $component, RendererInterface $default_renderer, Template $tpl): void + { + $tpl->setVariable('DETAILS', $this->maybeRender($default_renderer, ...$component->getDetails())); } protected function maybeRender(RendererInterface $default_renderer, Component\Component | null ...$values): ?string diff --git a/components/ILIAS/UI/src/Implementation/Component/Input/Container/Form/FormRendererFactory.php b/components/ILIAS/UI/src/Implementation/Component/Input/Container/Form/FormRendererFactory.php index 28a0ca681817..ed9914616eae 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Container/Form/FormRendererFactory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Container/Form/FormRendererFactory.php @@ -25,16 +25,15 @@ class FormRendererFactory extends Render\DefaultRendererFactory { - public const FORM_CONTEXTS_WITHOUT_BUTTONS = [ + /** @var string[] */ + public const array FORM_CONTEXTS_WITHOUT_BUTTONS = [ 'StateStatePrompt', 'RoundTripModal', ]; - public function getRendererInContext(Component\Component $component, array $contexts): Render\AbstractComponentRenderer + public function getRendererInContext(Component\Component $component, array $contexts): Render\ComponentRenderer { - $has_context_without_buttons = array_intersect(self::FORM_CONTEXTS_WITHOUT_BUTTONS, $contexts); - - if (! empty($has_context_without_buttons)) { + if ($this->context_matcher->isDescendantOfAny($contexts, self::FORM_CONTEXTS_WITHOUT_BUTTONS)) { return new FormWithoutSubmitButtonsContextRenderer( $this->ui_factory, $this->tpl_factory, @@ -46,15 +45,6 @@ public function getRendererInContext(Component\Component $component, array $cont $this->upload_limit_resolver ); } - return new Renderer( - $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/Input/Container/Form/FormWithoutSubmitButtonsContextRenderer.php b/components/ILIAS/UI/src/Implementation/Component/Input/Container/Form/FormWithoutSubmitButtonsContextRenderer.php index 1696a672c11f..8980a484ebca 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Container/Form/FormWithoutSubmitButtonsContextRenderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Container/Form/FormWithoutSubmitButtonsContextRenderer.php @@ -58,14 +58,20 @@ protected function renderFormWithoutSubmitButtons( static function (string $id) use ($component): string { return " // @TODO: we need to refactor the signal-management to prevent using jQuery here. - $(document).on('{$component->getSubmitSignal()}', function () { + $(document).on('{$component->getSubmitSignal()}', function (event, signal) { let form = document.getElementById('$id'); if (!form instanceof HTMLFormElement) { throw new Error(`Element '$id' is not an instance of HTMLFormElement.`); } - - // @TODO: we should use the triggering button as an emitter here. When doing - // so, please also change file.js processFormSubmissionHook(). + + // handle additional submit buttons here for the moment... + if (signal.options.formaction) { + event.target.setAttribute('formaction', signal.options.formaction); + event.target.setAttribute('form', '$id'); + form.requestSubmit(event.target); + return; + } + form.requestSubmit(); }); "; diff --git a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Factory.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Factory.php index 71fed74d932c..fec5c91648fe 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Factory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Factory.php @@ -179,6 +179,11 @@ public function hidden(): Hidden return new Hidden($this->data_factory, $this->refinery); } + public function hiddenList(): HiddenList + { + return new HiddenList($this->lng, $this->data_factory, $this->refinery, $this->hidden()); + } + public function colorSelect(string $label, ?string $byline = null): ColorSelect { return new ColorSelect($this->data_factory, $this->refinery, $label, $byline); diff --git a/components/ILIAS/UI/src/Implementation/Component/Input/Field/FieldRendererFactory.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/FieldRendererFactory.php index 5d3372541d81..ab423e178b64 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Field/FieldRendererFactory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/FieldRendererFactory.php @@ -25,12 +25,15 @@ class FieldRendererFactory extends Render\DefaultRendererFactory { - public function getRendererInContext(Component\Component $component, array $contexts): Render\AbstractComponentRenderer + /** @var string[] */ + protected const array FILTER_CONTEXT = ['StandardFilterContainerInput']; + /** @var string[] */ + protected const array FILTER_DURATION_CONTEXT = ['StandardFilterContainerInput', 'DurationFieldInput']; + + public function getRendererInContext(Component\Component $component, array $contexts): Render\ComponentRenderer { - if (in_array('DurationFieldInput', $contexts) - && in_array('DateTimeFieldInput', $contexts) - && in_array('StandardFilterContainerInput', $contexts)) { - return new DateTimeFilterContextRenderer( + if ($component instanceof DateTime && $this->context_matcher->isDirectDescendantOf($contexts, self::FILTER_DURATION_CONTEXT)) { + return new FilterDurationContextRenderer( $this->ui_factory, $this->tpl_factory, $this->lng, @@ -41,7 +44,7 @@ public function getRendererInContext(Component\Component $component, array $cont $this->upload_limit_resolver ); } - if (in_array('StandardFilterContainerInput', $contexts)) { + if ($this->context_matcher->isDescendantOfAny($contexts, self::FILTER_CONTEXT)) { return new FilterContextRenderer( $this->ui_factory, $this->tpl_factory, @@ -53,15 +56,6 @@ public function getRendererInContext(Component\Component $component, array $cont $this->upload_limit_resolver ); } - return new Renderer( - $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/Input/Field/DateTimeFilterContextRenderer.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/FilterDurationContextRenderer.php similarity index 95% rename from components/ILIAS/UI/src/Implementation/Component/Input/Field/DateTimeFilterContextRenderer.php rename to components/ILIAS/UI/src/Implementation/Component/Input/Field/FilterDurationContextRenderer.php index c105a223b83e..317a812f060e 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Field/DateTimeFilterContextRenderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/FilterDurationContextRenderer.php @@ -28,7 +28,7 @@ * Class DateTimeContextRenderer * @package ILIAS\UI\Implementation\Component\Input */ -class DateTimeFilterContextRenderer extends FilterContextRenderer +class FilterDurationContextRenderer extends FilterContextRenderer { protected function wrapInFormContext( FormInput $component, diff --git a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Group.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Group.php index 7ce7d2976a8b..3d1a7fc94906 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Group.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Group.php @@ -104,7 +104,7 @@ protected function getConstraintForRequirement(): ?Constraint */ public function getUpdateOnLoadCode(): Closure { - return function () { + return function ($id) { /* * Currently, there is no use case for Group here. The single Inputs * within the Group are responsible for handling getUpdateOnLoadCode(). diff --git a/components/ILIAS/UI/src/Implementation/Component/Input/Field/HiddenList.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/HiddenList.php new file mode 100755 index 000000000000..ecb6f9576010 --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/HiddenList.php @@ -0,0 +1,66 @@ + + */ +class HiddenList extends HasDynamicInputs implements CI\Field\HiddenList +{ + public function __construct( + \ILIAS\Language\Language $language, + \ILIAS\Data\Factory $data_factory, + \ILIAS\Refinery\Factory $refinery, + CI\Container\Form\FormInput $dynamic_input_template, + ) { + parent::__construct( + $language, + $data_factory, + $refinery, + $dynamic_input_template, + "", + null, + ); + } + + protected function isClientSideValueOk($value): bool + { + if (!is_array($value)) { + return false; + } + + return parent::isClientSideValueOk($value); + } + + public function getUpdateOnLoadCode(): Closure + { + return static function () { + }; + } + + protected function getConstraintForRequirement(): ?\ILIAS\Refinery\Constraint + { + return null; + } +} diff --git a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php index a3f6c9a6ea6f..507cdf22112b 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php @@ -157,6 +157,9 @@ public function render(Component\Component $component, RendererInterface $defaul case ($component instanceof F\Hidden): return $this->renderHiddenField($component); + case ($component instanceof F\HiddenList): + return $this->renderHiddenListField($component, $default_renderer); + case ($component instanceof F\ColorSelect): return $this->renderColorSelectField($component, $default_renderer); @@ -934,6 +937,18 @@ protected function renderHiddenField(F\Hidden $input): string return $template->get(); } + protected function renderHiddenListField(F\HiddenList $input, RendererInterface $default_renderer): string + { + $generated_inputs = $input->getGeneratedDynamicInputs(); + if (0 >= count($generated_inputs)) { + return ''; + } + $template = $this->getTemplate('tpl.hidden_list.html', true, true); + $template->setVariable('HIDDEN_INPUTS', $default_renderer->render($generated_inputs)); + $this->bindJSandApplyId($input, $template); + return $template->get(); + } + /** * @inheritdoc */ diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Entity.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Entity.php new file mode 100755 index 000000000000..468c10d9739c --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Entity.php @@ -0,0 +1,84 @@ +|null */ + protected ?array $subset = null; + + public function __construct( + protected C\Entity\EntityRetrieval $entity_retrieval + ) { + } + + public function withSubset(array $entity_ids): static + { + $this->checkArgListElements("entity_ids", $entity_ids, ["string", "int"]); + $clone = clone $this; + $clone->subset = $entity_ids; + return $clone; + } + + /** + * Yields Entities created by the given {@see C\Entity\EntityRetrieval}, + * respecting {@see C\Listing\Entity\Entity::withSubset()}. + * + * This is not implemented inside the renderer so we can add support for + * View Control's more easily at a later time. + * + * @return \Generator + */ + public function getEntities(\ILIAS\UI\Factory $ui_factory): Generator + { + if (null !== $this->subset) { + yield from $this->entity_retrieval->getEntitiesByIds($ui_factory, $this->getOrder(), $this->subset); + return; + } + + yield from $this->entity_retrieval->getEntities( + $ui_factory, + $this->getRange(), + $this->getOrder(), + null, + null, + null, + ); + } + + protected function getOrder(): Order + { + return new Order('', Order::ASC); + } + + protected function getRange(): Range + { + return new Range(0, PHP_INT_MAX); + } +} diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/EntityListing.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/EntityListing.php deleted file mode 100755 index ff840cdb2f28..000000000000 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/EntityListing.php +++ /dev/null @@ -1,71 +0,0 @@ -data = $data; - return $clone; - } - - /** - * @param array $additional_parameters - * @return \Generator - */ - public function getEntities( - \ILIAS\UI\Factory $ui_factory, - ?Range $range = null, - ?array $additional_parameters = null - ): \Generator { - $mapping = new class ($this->entity_mapping, $ui_factory) implements I\Mapping { - public function __construct( - protected I\RecordToEntity $mapper, - protected \ILIAS\UI\Factory $ui_factory - ) { - } - - public function map(mixed $record): IEntity\Entity - { - return $this->mapper->map($this->ui_factory, $record); - } - }; - - $additional_parameters = null; - return $this->data->getEntities($mapping, $range, $additional_parameters); - } -} diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Factory.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Factory.php index e8fe586bdceb..e683676c55fc 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Factory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Factory.php @@ -21,16 +21,17 @@ namespace ILIAS\UI\Implementation\Component\Listing\Entity; use ILIAS\UI\Component\Listing\Entity as I; +use ILIAS\UI\Component\Entity\EntityRetrieval; class Factory implements I\Factory { - public function standard(I\RecordToEntity $mapping): Standard + public function standard(EntityRetrieval $entity_retrieval): Standard { - return new Standard($mapping); + return new Standard($entity_retrieval); } - public function grid(I\RecordToEntity $mapping): Grid + public function grid(EntityRetrieval $entity_retrieval): Grid { - return new Grid($mapping); + return new Grid($entity_retrieval); } } diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Grid.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Grid.php index fcfb4b95da2c..bd070a2d4ba8 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Grid.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Grid.php @@ -22,6 +22,6 @@ use ILIAS\UI\Component\Listing\Entity as I; use ILIAS\UI\Component\Entity as IEntity; -class Grid extends EntityListing implements I\Grid +class Grid extends Entity implements I\Grid { } diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Renderer.php index 192769e8091a..f6b891e6db09 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Renderer.php @@ -23,8 +23,6 @@ use ILIAS\UI\Implementation\Render\AbstractComponentRenderer; use ILIAS\UI\Renderer as RendererInterface; use ILIAS\UI\Component; -use ILIAS\UI\Implementation\Render\ResourceRegistry; -use ILIAS\UI\Implementation\Render\Template; class Renderer extends AbstractComponentRenderer { @@ -34,17 +32,17 @@ class Renderer extends AbstractComponentRenderer public function render(Component\Component $component, RendererInterface $default_renderer): string { if ($component instanceof Standard) { - return $this->renderEntityListingStandard($component, $default_renderer); + return $this->renderStandardEntityList($component, $default_renderer); } if ($component instanceof Grid) { - return $this->renderEntityListingGrid($component, $default_renderer); + return $this->renderGridEntityList($component, $default_renderer); } $this->cannotHandleComponent($component); } - protected function renderEntityListingStandard(EntityListing $component, RendererInterface $default_renderer): string + protected function renderStandardEntityList(Standard $component, RendererInterface $default_renderer): string { - $tpl = $this->getTemplate('tpl.entitylisting.html', true, true); + $tpl = $this->getTemplate('tpl.entity_standard.html', true, true); foreach ($component->getEntities( $this->getUIFactory() @@ -55,9 +53,10 @@ protected function renderEntityListingStandard(EntityListing $component, Rendere } return $tpl->get(); } - protected function renderEntityListingGrid(EntityListing $component, RendererInterface $default_renderer): string + + protected function renderGridEntityList(Grid $component, RendererInterface $default_renderer): string { - $tpl = $this->getTemplate('tpl.entitylistinggrid.html', true, true); + $tpl = $this->getTemplate('tpl.entity_grid.html', true, true); foreach ($component->getEntities( $this->getUIFactory(), diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Standard.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Standard.php index 3d7f1d398d31..537f2942429e 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Standard.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Standard.php @@ -23,6 +23,6 @@ use ILIAS\UI\Component\Listing\Entity as I; use ILIAS\UI\Component\Entity as IEntity; -class Standard extends EntityListing implements I\Standard +class Standard extends Entity implements I\Standard { } diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/ListingRendererFactory.php b/components/ILIAS/UI/src/Implementation/Component/Listing/ListingRendererFactory.php index 8ffbb8a2c766..5229babcdc8b 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/ListingRendererFactory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/ListingRendererFactory.php @@ -36,7 +36,7 @@ class ListingRendererFactory extends DefaultRendererFactory public function getRendererInContext(Component $component, array $contexts): ComponentRenderer { - if (!empty(array_intersect(self::TABLE_COLUMN_CONTEXTS, $contexts))) { + if ($this->context_matcher->isDirectDescendantOfAny($contexts, self::TABLE_COLUMN_CONTEXTS)) { return new TableColumnContextRenderer( $this->ui_factory, $this->tpl_factory, @@ -48,7 +48,6 @@ public function getRendererInContext(Component $component, array $contexts): Com $this->upload_limit_resolver, ); } - return parent::getRendererInContext($component, $contexts); } } diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Renderer.php index da00b21b1f3c..777ae9049c25 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Renderer.php @@ -113,7 +113,7 @@ protected function renderPropertyList( Property $component, RendererInterface $default_renderer ): string { - $tpl = $this->getTemplate("tpl.propertylisting.html", true, true); + $tpl = $this->getTemplate("tpl.property.html", true, true); foreach ($component->getItems() as [$label, $value, $show_label]) { $tpl->setCurrentBlock("property"); diff --git a/components/ILIAS/UI/src/Implementation/Component/Menu/MenuRendererFactory.php b/components/ILIAS/UI/src/Implementation/Component/Menu/MenuRendererFactory.php index c72c32a93518..d38af4d97ddc 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Menu/MenuRendererFactory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Menu/MenuRendererFactory.php @@ -28,12 +28,16 @@ */ class MenuRendererFactory extends DefaultRendererFactory { + /** @var string[] */ + protected const array TREE_SELECT_CONTEXTS = [ + 'TreeSelectFieldInput', + 'TreeMultiSelectFieldInput', + ]; + public function getRendererInContext(Component $component, array $contexts): ComponentRenderer { - if (in_array('TreeSelectFieldInput', $contexts, true) || - in_array('TreeMultiSelectFieldInput', $contexts, true) - ) { - return new FieldContextRenderer( + if ($this->context_matcher->isDirectDescendantOfAny($contexts, self::TREE_SELECT_CONTEXTS)) { + return new TreeSelectContextRenderer( $this->ui_factory, $this->tpl_factory, $this->lng, @@ -44,7 +48,6 @@ public function getRendererInContext(Component $component, array $contexts): Com $this->upload_limit_resolver, ); } - return parent::getRendererInContext($component, $contexts); } } diff --git a/components/ILIAS/UI/src/Implementation/Component/Menu/FieldContextRenderer.php b/components/ILIAS/UI/src/Implementation/Component/Menu/TreeSelectContextRenderer.php similarity index 97% rename from components/ILIAS/UI/src/Implementation/Component/Menu/FieldContextRenderer.php rename to components/ILIAS/UI/src/Implementation/Component/Menu/TreeSelectContextRenderer.php index 4354dea99dbe..8714ffd3baa7 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Menu/FieldContextRenderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Menu/TreeSelectContextRenderer.php @@ -32,7 +32,7 @@ * * @author Thibeau Fuhrer */ -class FieldContextRenderer extends Renderer +class TreeSelectContextRenderer extends Renderer { public function render(Component\Component $component, RendererInterface $default_renderer): string { diff --git a/components/ILIAS/UI/src/Implementation/Component/MessageBox/MessageBoxRendererFactory.php b/components/ILIAS/UI/src/Implementation/Component/MessageBox/MessageBoxRendererFactory.php index 07c8a41de2d4..42ab918dcd5f 100644 --- a/components/ILIAS/UI/src/Implementation/Component/MessageBox/MessageBoxRendererFactory.php +++ b/components/ILIAS/UI/src/Implementation/Component/MessageBox/MessageBoxRendererFactory.php @@ -25,9 +25,12 @@ class MessageBoxRendererFactory extends Render\DefaultRendererFactory { - public function getRendererInContext(Component\Component $component, array $contexts): Render\AbstractComponentRenderer + /** @var string[] */ + protected const array PROMPT_STATE_CONTEXT = ['StateStatePrompt']; + + public function getRendererInContext(Component\Component $component, array $contexts): Render\ComponentRenderer { - if (in_array('StateStatePrompt', $contexts)) { + if ($this->context_matcher->isDirectDescendantOf($contexts, self::PROMPT_STATE_CONTEXT)) { return new PromptContextRenderer( $this->ui_factory, $this->tpl_factory, @@ -39,15 +42,6 @@ public function getRendererInContext(Component\Component $component, array $cont $this->upload_limit_resolver ); } - return new Renderer( - $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/Prompt/Factory.php b/components/ILIAS/UI/src/Implementation/Component/Prompt/Factory.php index da21193aa8f7..cac13f4109f4 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/Factory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/Factory.php @@ -20,24 +20,25 @@ namespace ILIAS\UI\Implementation\Component\Prompt; +use ILIAS\Data\URI; use ILIAS\UI\Component\Prompt as I; use ILIAS\UI\Implementation\Component\SignalGeneratorInterface; -use ILIAS\Data\URI; class Factory implements I\Factory { public function __construct( - protected SignalGeneratorInterface $signal_generator + protected SignalGeneratorInterface $signal_generator, + protected State\Factory $state_factory, ) { } - public function standard(URI $async_url): Standard + public function standard(URI $async_url): Prompt { - return new Standard($this->signal_generator, $async_url); + return new Prompt($this->signal_generator, $async_url); } public function state(): State\Factory { - return new State\Factory(); + return $this->state_factory; } } diff --git a/components/ILIAS/UI/src/Implementation/Component/Prompt/Prompt.php b/components/ILIAS/UI/src/Implementation/Component/Prompt/Prompt.php index e9870cd6539c..5f8e99ecf9de 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/Prompt.php +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/Prompt.php @@ -22,25 +22,24 @@ use ILIAS\UI\Component\Prompt as I; use ILIAS\UI\Component\Signal; +use ILIAS\UI\Implementation\Component\SignalGeneratorInterface; use ILIAS\UI\Implementation\Component\ComponentHelper; use ILIAS\UI\Implementation\Component\JavaScriptBindable; use ILIAS\UI\Implementation\Component\SignalGenerator; - use ILIAS\Data\URI; -/** - * - */ -abstract class Prompt implements I\Prompt + +class Prompt implements I\Prompt { use ComponentHelper; use JavaScriptBindable; + protected ?string $title = null; protected Signal $show_signal; protected Signal $close_signal; public function __construct( - SignalGenerator $signal_generator, + SignalGeneratorInterface $signal_generator, protected URI $async_url ) { $this->show_signal = $signal_generator->create(); @@ -52,6 +51,18 @@ public function getAsyncUrl(): URI return $this->async_url; } + public function getTitle(): ?string + { + return $this->title; + } + + public function withTitle(string $title): static + { + $clone = clone $this; + $clone->title = $title; + return $clone; + } + public function getShowSignal(?URI $uri = null): Signal { $target = $uri ?? $this->async_url; diff --git a/components/ILIAS/UI/src/Implementation/Component/Prompt/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Prompt/Renderer.php index df4fcc5f922f..c9b7bba56fd1 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/Renderer.php @@ -33,7 +33,7 @@ class Renderer extends AbstractComponentRenderer */ public function render(Component\Component $component, RendererInterface $default_renderer): string { - if ($component instanceof Component\Prompt\Prompt) { + if ($component instanceof Prompt) { return $this->renderPrompt($component, $default_renderer); } @@ -49,18 +49,24 @@ public function registerResources(ResourceRegistry $registry): void $registry->register('assets/js/prompt.min.js'); } - protected function renderPrompt(Component\Prompt\Prompt $component, RendererInterface $default_renderer): string + protected function renderPrompt(Prompt $component, RendererInterface $default_renderer): string { $tpl = $this->getTemplate('tpl.prompt.html', true, true); + + $tpl->setVariable('TITLE', (string) $component->getTitle()); + $tpl->setVariable('ERROR_MESSAGE_BOX', $default_renderer->render( + $this->getUIFactory()->messageBox()->failure($this->txt('prompt_request_error')), + )); + $show_signal = $component->getShowSignal(); $close_signal = $component->getCloseSignal(); $url = $component->getAsyncUrl()->__toString(); $component = $component->withAdditionalOnLoadCode( - fn($id) => " - il.UI.prompt.init('$id'); - $(document).on('$show_signal', (e, data) => {il.UI.prompt.get('$id').show(data.options.url);}) - $(document).on('$close_signal', () => {il.UI.prompt.get('$id').close();}) - " + fn($id) => "(function() { + const prompt = il.UI.Prompt.createPrompt(document.getElementById('$id')); + $(document).on('$show_signal', (e, data) => { prompt.loadState(data.options.url); }); + $(document).on('$close_signal', () => { prompt.close(); }); + })();" ); $id = $this->bindJavaScript($component); diff --git a/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Factory.php b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Factory.php index 3c501c05be00..0fdc7e94e2fb 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Factory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Factory.php @@ -20,27 +20,23 @@ namespace ILIAS\UI\Implementation\Component\Prompt\State; -use ILIAS\UI\Component\Prompt as I; -use ILIAS\UI\Implementation\Component\SignalGeneratorInterface; use ILIAS\Data\URI; +use ILIAS\UI\Component as C; -class Factory implements I\State\Factory +class Factory implements C\Prompt\State\Factory { - public function show( - I\IsPromptContent $primary_content, - I\IsPromptContent ...$secondary_content - ): State { - return new State($primary_content, ...$secondary_content); + public function show(C\Prompt\IsPromptContent $primary_content, C\Prompt\IsPromptContent ...$secondary_content): State + { + return new State(Instruction::SHOW, null, $primary_content, $secondary_content); } public function close(): State { - return new State(null)->withCloseModal(true); + return new State(Instruction::CLOSE, null, null); } public function redirect(URI $redirect): State { - return new State(null)->withRedirect($redirect); + return new State(Instruction::REDIRECT, $redirect, null); } - } diff --git a/components/ILIAS/UI/resources/js/Prompt/src/prompt.js b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Instruction.php similarity index 63% rename from components/ILIAS/UI/resources/js/Prompt/src/prompt.js rename to components/ILIAS/UI/src/Implementation/Component/Prompt/State/Instruction.php index 4518def81d10..b6557f11450a 100644 --- a/components/ILIAS/UI/resources/js/Prompt/src/prompt.js +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Instruction.php @@ -1,3 +1,5 @@ + + */ +enum Instruction: string +{ + case SHOW = 'show'; + case CLOSE = 'close'; + case REDIRECT = 'redirect'; +} diff --git a/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Renderer.php index ecd7a847a2c6..b0d8a1038efe 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Renderer.php @@ -22,17 +22,18 @@ use ILIAS\UI\Implementation\Render\AbstractComponentRenderer; use ILIAS\UI\Implementation\Render\Template; -use ILIAS\UI\Implementation\Component\Prompt\State\State; use ILIAS\UI\Renderer as RendererInterface; -use ILIAS\UI\Component; -use ILIAS\UI\Component\Prompt\IsPromptContent; +use ILIAS\UI\Component as C; +use LogicException; +use ILIAS\UI\Implementation\Component as I; +use ILIAS\Data\URI; class Renderer extends AbstractComponentRenderer { /** * @inheritdoc */ - public function render(Component\Component $component, RendererInterface $default_renderer): string + public function render(C\Component $component, RendererInterface $default_renderer): string { if ($component instanceof State) { return $this->renderState($component, $default_renderer); @@ -41,71 +42,63 @@ public function render(Component\Component $component, RendererInterface $defaul $this->cannotHandleComponent($component); } - protected function renderState(State $component, RendererInterface $default_renderer): string { - $tpl = $this->getTemplate('tpl.promptstate.html', true, true); - $tpl->setVariable('COMMAND', $component->getCommand()); + $tpl = $this->getTemplate('tpl.prompt_state.html', true, true); - foreach ($component->getParameters() as $key => $value) { - $tpl->setCurrentBlock('param'); - $tpl->setVariable('KEY', $key); - $tpl->setVariable('VALUE', $value); - $tpl->parseCurrentBlock(); - } + $instruction = $component->getInstruction(); - $secondary_content_components = $component->getSecondaryContent(); - foreach ($secondary_content_components as $secondary_content_component) { - if ($secondary_content_component instanceof Component\MessageBox\MessageBox) { - $tpl->setCurrentBlock('top_secondary'); - $tpl->setVariable('TOP_SECONDARY_CONTENT', $default_renderer->render($secondary_content_component)); - $tpl->parseCurrentBlock(); - continue; - } - $tpl->setCurrentBlock('bottom_secondary'); - $tpl->setVariable('BOTTOM_SECONDARY_CONTENT', $default_renderer->render($secondary_content_component)); - $tpl->parseCurrentBlock(); - } + $tpl->setVariable('INSTRUCTION', $instruction->value); + $tpl->setVariable('REDIRECT_URL', (string) $component->getRedirect()); - $primary_content_component = $component->getPrimaryContent(); - if ($primary_content_component === null) { + if ($instruction === Instruction::CLOSE || $instruction === Instruction::REDIRECT) { return $tpl->get(); } - $tpl->setVariable('PRIMARY_CONTENT', $default_renderer->render($primary_content_component)); - $tpl->setVariable('TITLE', $component->getTitle()); - - $buttons = $this->deriveButtonsFromPrimaryContent($primary_content_component); + $primary_content = $component->getPrimaryContent(); + if (null === $primary_content) { + throw new LogicException('Prompt State Show MUST have primary content.'); + } - $buttons[] = $this->getUIFactory()->button() - ->standard($this->txt('close'), '') - ->withOnLoadCode( - fn($id) => "$('#$id').on('click', (e)=> { - let promptId = e.target.closest('dialog').parentNode.id; - il.UI.prompt.get(promptId).close(); - });" - ); + $tpl->setVariable('TITLE', (string) $component->getTitle()); + $tpl->setVariable('PRIMARY_CONTENT', $default_renderer->render($primary_content)); + $tpl->setVariable('BUTTONS', $default_renderer->render($this->getPromptButtons($primary_content))); + + foreach ($component->getSecondaryContent() as $secondary_content) { + // checking interface level for placement is sufficient + if ($secondary_content instanceof C\MessageBox\MessageBox) { + $tpl->setCurrentBlock('secondary_content_top'); + $tpl->setVariable('SECONDARY_CONTENT_TOP', $default_renderer->render($secondary_content)); + } else { + $tpl->setCurrentBlock('secondary_content_bottom'); + $tpl->setVariable('SECONDARY_CONTENT_BOTTOM', $default_renderer->render($secondary_content)); + } + $tpl->parseCurrentBlock(); + } - $tpl->setVariable('BUTTONS', $default_renderer->render($buttons)); return $tpl->get(); } - /** - * @return Component\Button\Button[] - */ - protected function deriveButtonsFromPrimaryContent(IsPromptContent $content): array + /** @return I\Button\Button[] */ + protected function getPromptButtons(C\Prompt\IsPromptContent $content): array { - $buttons = []; - if ($content instanceof Component\MessageBox\MessageBox) { - $buttons = $content->getButtons(); + if ($content instanceof I\Input\Container\Form\Standard) { + return $this->createFormSubmitButtons($content); } - if ($content instanceof Component\Input\Container\Form\Form) { - $submit_button = $this->getUIFactory()->button()->standard( - $content->getSubmitLabel() ?? $this->txt("save"), - $content->getSubmitSignal() - ); - $buttons[] = $submit_button; + if ($content instanceof I\MessageBox\MessageBox) { + return $content->getButtons(); } + return []; + } + + /** @return I\Button\Button[] */ + protected function createFormSubmitButtons(I\Input\Container\Form\Standard $form): array + { + foreach ($form->getAdditionalFormActions() as $action => $label) { + $buttons[] = $this->getUIFactory()->button()->standard($label, $form->getSubmitSignal()->withOptions(['formaction' => $action])); + } + $buttons[] = $this->getUIFactory()->button()->standard($form->getSubmitLabel() ?? $this->txt('save'), $form->getSubmitSignal()); + return $buttons; } } diff --git a/components/ILIAS/UI/src/Implementation/Component/Prompt/State/State.php b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/State.php index 02bc2338569c..53d0691c199e 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/State/State.php +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/State.php @@ -26,100 +26,53 @@ use ILIAS\UI\Implementation\Component\ComponentHelper; use ILIAS\Data\URI; -/** - * - */ class State implements I\State\State { use ComponentHelper; - public const string CMD_CLOSE = 'close'; - public const string CMD_REDIRECT = 'redirect'; - - protected array $buttons = []; - protected string $cmd = 'show'; - protected array $params = []; - protected string $title = ''; - /** @var I\IsPromptContent[] */ - protected array $secondary_content = []; + protected ?string $title = null; + /** + * @param I\IsPromptContent[] $secondary_content + */ public function __construct( + protected Instruction $instruction, + protected ?URI $redirect, protected ?I\IsPromptContent $primary_content, - I\IsPromptContent ...$secondary_content + protected array $secondary_content = [], ) { - $this->secondary_content = $secondary_content; } - public function getTitle(): string - { - return $this->title; - } - - public function withTitle(string $title): self + public function withTitle(string $title): static { $clone = clone $this; $clone->title = $title; return $clone; } - public function withPrimaryContent(I\IsPromptContent $content): self + public function getTitle(): ?string { - $clone = clone $this; - $clone->primary_content = $content; - return $clone; + return $this->title; } - public function withAdditionalSecondaryContent(I\IsPromptContent ...$content): self + public function getInstruction(): Instruction { - $clone = clone $this; - $clone->secondary_content = array_merge($clone->secondary_content, $content); - return $clone; + return $this->instruction; + } + + public function getRedirect(): ?URI + { + return $this->redirect; } - /** - * @return Component[] - */ public function getPrimaryContent(): ?I\IsPromptContent { return $this->primary_content; } - /** - * @return I\IsPromptContent[] - */ + /** @return I\IsPromptContent[] */ public function getSecondaryContent(): array { return $this->secondary_content; } - - public function withCloseModal(bool $flag): self - { - return $this->withCommand($flag ? self::CMD_CLOSE : ''); - } - - public function withRedirect(URI $redirect): self - { - $clone = $this->withCommand(self::CMD_REDIRECT); - $clone->params = [ - self::CMD_REDIRECT => $redirect->__toString() - ]; - return $clone; - } - - protected function withCommand(string $cmd) - { - $clone = clone $this; - $clone->cmd = $cmd; - return $clone; - } - - public function getCommand(): string - { - return $this->cmd; - } - - public function getParameters(): array - { - return $this->params; - } } diff --git a/components/ILIAS/UI/src/Implementation/Component/Signal.php b/components/ILIAS/UI/src/Implementation/Component/Signal.php index 57d208df2c9c..5b2afdf6e90f 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Signal.php +++ b/components/ILIAS/UI/src/Implementation/Component/Signal.php @@ -44,6 +44,15 @@ public function getId(): string return $this->id; } + public function withOptions(array $options): static + { + $clone = clone $this; + foreach ($options as $key => $value) { + $clone->addOption($key, $value); + } + return $clone; + } + public function getOptions(): array { return $this->options; diff --git a/components/ILIAS/UI/src/Implementation/DefaultRenderer.php b/components/ILIAS/UI/src/Implementation/DefaultRenderer.php index cd0133743a35..3149af457de5 100755 --- a/components/ILIAS/UI/src/Implementation/DefaultRenderer.php +++ b/components/ILIAS/UI/src/Implementation/DefaultRenderer.php @@ -77,17 +77,10 @@ public function render($component, ?Renderer $root = null) */ public function renderAsync($component, ?Renderer $root = null) { - $root = $root ?? $this; + // prevent JavaScript of render() from leaking into async responses + $this->java_script_binding->clearOnLoadCodeAsync(); - $out = ''; - if (is_array($component)) { - foreach ($component as $_component) { - $out .= $root->renderAsync($_component); - } - } else { - $out = $this->render($component, $root) . $this->java_script_binding->getOnLoadCodeAsync(); - } - return $out; + return $this->render($component, $root) . $this->java_script_binding->getOnLoadCodeAsync(); } /** diff --git a/components/ILIAS/UI/src/Implementation/Render/ContextMatcher.php b/components/ILIAS/UI/src/Implementation/Render/ContextMatcher.php new file mode 100644 index 000000000000..6b6a196b49fe --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Render/ContextMatcher.php @@ -0,0 +1,131 @@ + + */ +class ContextMatcher +{ + /** + * Returns true if any canonical name of $search_any is a direct ancestor of + * the last canonical name inside the context. + * + * @param string[] $context_asc canonical names (first to last) + * @param string[] $search_any canonical names + */ + public function isDirectDescendantOfAny(array $context_asc, array $search_any): bool + { + // ensure minimum context size allowing direct descendants + $context_size = count($context_asc); + if (2 > $context_size) { + return false; + } + return in_array($context_asc[$context_size - 2], $search_any, true); + } + + /** + * Returns true the exact canonical names of $search_exactly are direct ancestors + * of the last canonical name inside the context. + * + * @param string[] $context_asc canonical names (first to last) + * @param string[] $search_exactly canonical names (first to last) + */ + public function isDirectDescendantOf(array $context_asc, array $search_exactly): bool + { + // ensure minimum context size allowing direct descendants + $search_size = count($search_exactly); + if (0 === $search_size || $search_size + 1 > count($context_asc)) { + return false; + } + $ancestors = array_slice($context_asc, 0, -1); + return $search_exactly === array_slice($ancestors, -$search_size); + } + + /** + * Returns true if any canonical name of $search_any is an indirect ancestor + * of the last canonical name inside the context. + * + * @param string[] $context_asc canonical names (first to last) + * @param string[] $search_any canonical names + */ + public function isIndirectDescendantOfAny(array $context_asc, array $search_any): bool + { + // ensure minimum context size allowing direct descendants + if (3 > count($context_asc)) { + return false; + } + $indirect_ancestors = array_slice($context_asc, 0, -2); + return [] !== array_intersect($indirect_ancestors, $search_any); + } + + /** + * Returns true the exact canonical names of $search_exactly are indirect ancestors + * of the last canonical name inside the context. + * + * @param string[] $context_asc canonical names (first to last) + * @param string[] $search_exactly canonical names (first to last) + */ + public function isIndirectDescendantOf(array $context_asc, array $search_exactly): bool + { + // ensure minimum context size allowing direct descendants + $search_size = count($search_exactly); + if (0 === $search_size || $search_size + 2 > count($context_asc)) { + return false; + } + $indirect_ancestors = array_slice($context_asc, 0, -2); + $last_start = count($indirect_ancestors) - $search_size; + for ($start = 0; $last_start >= $start; $start++) { + if ($search_exactly === array_slice($indirect_ancestors, $start, $search_size)) { + return true; + } + } + return false; + } + + /** + * Returns true if any canonical name of $search_any is an ancestor of any + * kind of the last canonical name inside the context. + * + * @param string[] $context_asc canonical names (first to last) + * @param string[] $search_any canonical names + */ + public function isDescendantOfAny(array $context_asc, array $search_any): bool + { + return $this->isDirectDescendantOfAny($context_asc, $search_any) + || $this->isIndirectDescendantOfAny($context_asc, $search_any); + } + + /** + * Returns true the exact canonical names of $search_exactly are indirect ancestors + * of any kind of the last canonical name inside the context. + * + * @param string[] $context_asc canonical names (first to last) + * @param string[] $search_exactly canonical names (first to last) + */ + public function isDescendantOf(array $context_asc, array $search_exactly): bool + { + return $this->isDirectDescendantOf($context_asc, $search_exactly) + || $this->isIndirectDescendantOf($context_asc, $search_exactly); + } +} diff --git a/components/ILIAS/UI/src/Implementation/Render/DefaultRendererFactory.php b/components/ILIAS/UI/src/Implementation/Render/DefaultRendererFactory.php index 7c61d196f6f8..258a7450ab75 100755 --- a/components/ILIAS/UI/src/Implementation/Render/DefaultRendererFactory.php +++ b/components/ILIAS/UI/src/Implementation/Render/DefaultRendererFactory.php @@ -31,6 +31,7 @@ class DefaultRendererFactory implements RendererFactory { public function __construct( + protected ContextMatcher $context_matcher, protected FactoryInternal $ui_factory, protected TemplateFactory $tpl_factory, protected Language $lng, diff --git a/components/ILIAS/UI/src/Implementation/Render/FSLoader.php b/components/ILIAS/UI/src/Implementation/Render/FSLoader.php index 851679081989..cd2b22e1425a 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\Entity\Entity; use ILIAS\UI\Implementation\Component\Listing\Listing; /** @@ -52,6 +53,7 @@ public function __construct( private RendererFactory $message_box_renderer_factory, private RendererFactory $form_renderer_factory, private RendererFactory $menu_renderer_factory, + private RendererFactory $entity_renderer_factory, private RendererFactory $listing_renderer_factory, ) { } @@ -86,6 +88,9 @@ public function getRendererFactoryFor(Component $component): RendererFactory if ($component instanceof Button) { return $this->button_renderer_factory; } + if ($component instanceof Entity) { + return $this->entity_renderer_factory; + } if ($component instanceof Listing) { return $this->listing_renderer_factory; } diff --git a/components/ILIAS/UI/src/Implementation/Render/JavaScriptBinding.php b/components/ILIAS/UI/src/Implementation/Render/JavaScriptBinding.php index 86db285bc070..f42f42a6e805 100755 --- a/components/ILIAS/UI/src/Implementation/Render/JavaScriptBinding.php +++ b/components/ILIAS/UI/src/Implementation/Render/JavaScriptBinding.php @@ -42,4 +42,21 @@ public function addOnLoadCode(string $code): void; * inside '; - $this->code = []; return $js_out; } + + public function clearOnLoadCodeAsync(): void + { + $this->code = []; + } } diff --git a/components/ILIAS/UI/src/examples/Entity/Standard/base.php b/components/ILIAS/UI/src/examples/Entity/Standard/base.php index 33b6dd521876..3fb27a80cc83 100755 --- a/components/ILIAS/UI/src/examples/Entity/Standard/base.php +++ b/components/ILIAS/UI/src/examples/Entity/Standard/base.php @@ -59,6 +59,7 @@ function base() // creating the entity object now so it can be filled in the logic section $entity = $f->entity()->standard( + 'event-demo', $primary_id, $secondary_id ); diff --git a/components/ILIAS/UI/src/examples/Entity/Standard/semantic_groups.php b/components/ILIAS/UI/src/examples/Entity/Standard/semantic_groups.php index 36ff079ce184..01035410e1d7 100755 --- a/components/ILIAS/UI/src/examples/Entity/Standard/semantic_groups.php +++ b/components/ILIAS/UI/src/examples/Entity/Standard/semantic_groups.php @@ -45,7 +45,7 @@ function semantic_groups() $f = $DIC->ui()->factory(); $renderer = $DIC->ui()->renderer(); - $entity = $f->entity()->standard('Primary Identifier', 'Secondary Identifier') + $entity = $f->entity()->standard('demo-entity', 'Primary Identifier', 'Secondary Identifier') ->withBlockingAvailabilityConditions($f->legacy()->content('Blocking Conditions')) ->withFeaturedProperties($f->legacy()->content('Featured Properties')) ->withPersonalStatus($f->legacy()->content('Personal Status')) diff --git a/components/ILIAS/UI/src/examples/Entity/Standard/video_object.php b/components/ILIAS/UI/src/examples/Entity/Standard/video_object.php index 1c53f7dadc8d..899a84c1cdb1 100644 --- a/components/ILIAS/UI/src/examples/Entity/Standard/video_object.php +++ b/components/ILIAS/UI/src/examples/Entity/Standard/video_object.php @@ -1,5 +1,21 @@ entity()->standard( + 'video-demo', $primary_id, $secondary_id ); diff --git a/components/ILIAS/UI/src/examples/Input/Field/HiddenList/base.php b/components/ILIAS/UI/src/examples/Input/Field/HiddenList/base.php new file mode 100755 index 000000000000..ee0eb26fc537 --- /dev/null +++ b/components/ILIAS/UI/src/examples/Input/Field/HiddenList/base.php @@ -0,0 +1,45 @@ + + * The example shows how to create and render a basic Hidden List Input and + * attach it to a form. This example does not contain any data processing. + * + * expected output: > + * User doesn't see a input field, ILIAS shows an empty form. + * --- + */ +function base(): string +{ + global $DIC; + + $factory = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + + $input = $factory->input()->field()->hiddenList()->withValue(['some_id_1', 'some_id_2', '...']); + + $form = $factory->input()->container()->form()->standard("#", [$input]); + + return $renderer->render($form); +} diff --git a/components/ILIAS/UI/src/examples/Listing/Entity/Grid/base.php b/components/ILIAS/UI/src/examples/Listing/Entity/Grid/base.php index ee98483ace68..39f87e3d5c33 100644 --- a/components/ILIAS/UI/src/examples/Listing/Entity/Grid/base.php +++ b/components/ILIAS/UI/src/examples/Listing/Entity/Grid/base.php @@ -4,12 +4,12 @@ namespace ILIAS\UI\Examples\Listing\Entity\Grid; +use Generator; +use ILIAS\Data\Order; +use ILIAS\Data\Range; use ILIAS\UI\Factory as UIFactory; -use ILIAS\UI\Component\Listing\Entity\RecordToEntity; -use ILIAS\UI\Component\Listing\Entity\DataRetrieval; use ILIAS\UI\Component\Entity\Entity; -use ILIAS\UI\Component\Listing\Entity\Mapping; -use ILIAS\Data\Range; +use ILIAS\UI\Component\Entity\EntityRetrieval; /** * --- @@ -28,101 +28,115 @@ function base(): string $f = $DIC->ui()->factory(); $renderer = $DIC->ui()->renderer(); - $record_to_entity = new class () implements RecordToEntity { - public function map(UIFactory $ui_factory, mixed $record): Entity - { - $glyph_user = $ui_factory->symbol()->glyph()->user() - ->withLabel("Created by"); - - $glyph_calendar = $ui_factory->symbol()->glyph()->calendar() - ->withLabel("Upload date"); - - $glyph_duration = $ui_factory->symbol()->glyph()->time() - ->withLabel("Duration"); - - list($title, $thumbnail_url, $creator, $availability, $description, $duration, $add_workflow, $date) = $record; - $managing_actions = [ - $ui_factory->button()->shy("Edit", "#"), - $ui_factory->button()->shy("Move", "#"), - ]; - $entity = $ui_factory->entity()->standard( - $ui_factory->link()->standard($title, ""), - $ui_factory->image()->responsive($thumbnail_url, $title)->withAction("#") - ) - ->withFeaturedProperties( - $ui_factory->listing()->property() - ->withProperty($glyph_user, $creator) - ) - ->withManagingActions(...$managing_actions) - ->withMainDetails( - $ui_factory->listing()->property() - ->withProperty($glyph_duration, $description, false) - ->withProperty($glyph_duration, $duration) - ->withProperty($glyph_calendar, $date) - ) - ->withPrioritizedReactions($ui_factory->button()->shy("Like", "#")->withSymbol($ui_factory->symbol()->glyph()->like())) - ; - if ($availability) { - $entity = $entity->withBlockingAvailabilityConditions( - $ui_factory->listing()->property() - ->withProperty("Status", $ui_factory->legacy()->content($availability), false) - ); - } - if ($add_workflow) { - $workflow_factory = $ui_factory->listing()->workflow(); - $dummy_step = $workflow_factory->step('', ''); - - $steps = [ - $workflow_factory->step("Upload video file", "Upload an .mp4 file or start a recording.", "#") - ->withAvailability($dummy_step::NOT_ANYMORE)->withStatus($dummy_step::SUCCESSFULLY), - $workflow_factory->step("Cut video", "Trim or remove parts of the video.", "#") - ->withAvailability($dummy_step::NOT_ANYMORE)->withStatus($dummy_step::NOT_STARTED), - $workflow_factory->step("Add subtitles", "You must upload or generate subtitles for every video.", "#") - ->withAvailability($dummy_step::AVAILABLE)->withStatus($dummy_step::SUCCESSFULLY), - $workflow_factory->step("Publish", "Set who can see this video.", "#") - ->withAvailability($dummy_step::AVAILABLE)->withStatus($dummy_step::NOT_STARTED), - ]; - - $video_workflow = $workflow_factory->linear("Video Curation", $steps); - - $entity = $entity->withWorkflow($video_workflow); - } - return $entity; - } - }; - $glyph_eye_closed = $f->symbol()->glyph()->eyeclosed(); - $glyph_with_text = $renderer->render($glyph_eye_closed) . " offline"; + $glyph_with_text = $renderer->render($glyph_eye_closed) . ' offline'; $card_data = [ - ['Snowboarding for beginners - How to avoid falling on your face', 'assets/ui-examples/images/Image/ski_widescreen-thumbnail.jpg', "Bobby's School of Snowboarding Austria", null, 'This is the perfect start for anyone wanting to get on a snowboard. We talk the best gear and the best locations for a beginner. And no worries - it is not expensive: Renting equipment will work just fine. Then we end with some first exercises to get the stability needed to tackle your first slope', '23 min', false, '01.01.2026'], - ['The History of Bridges', 'assets/ui-examples/images/Image/sanfrancisco_widescreen-thumbnail.jpg', 'BBC England', $glyph_with_text,'One of the most monumental achievements of human kind is the invention of bridges. Crossing streets, rivers and sometimes oceans became a huge pillar for our our modern infrastructure. This documentary looks at the different types of bridges and how they have been developed and engineered throughout different cultures and centuries','90 min', true, "01.11.2026"], - ['Mountains through the ages - the formation of giants', 'assets/ui-examples/images/Image/mountains_widescreen-thumbnail.jpg','ARD/ZDF, Canal Plus', null, "A fascinating look on the forces of nature that are able to move unimaginable tons of rocks. Find out how seemingly immovable landscape has transformed drastically through the incredible forces set free by earthquakes, vulcanos and water. This award-winning documentary traces the movement of the world's greatest mountain ranges throughout millions of years.", "45 min", false, "11.10.2026"], - ['Snowboarding for beginners - How to avoid falling on your face', 'assets/ui-examples/images/Image/ski_widescreen-thumbnail.jpg', "Bobby's School of Snowboarding Austria", null, 'This is the perfect start for anyone wanting to get on a snowboard. We talk the best gear and the best locations for a beginner. And no worries - it is not expensive: Renting equipment will work just fine. Then we end with some first exercises to get the stability needed to tackle your first slope', '23 min', false, "28.02.2026"], - ['The History of Bridges', 'assets/ui-examples/images/Image/sanfrancisco_widescreen-thumbnail.jpg', 'BBC England', $glyph_with_text,'One of the most monumental achievements of human kind is the invention of bridges. Crossing streets, rivers and sometimes oceans became a huge pillar for our our modern infrastructure. This documentary looks at the different types of bridges and how they have been developed and engineered throughout different cultures and centuries','90 min', true, "15.12.2025"], - ['Mountains through the ages - the formation of giants', 'assets/ui-examples/images/Image/mountains_widescreen-thumbnail.jpg','ARD/ZDF, Canal Plus', null, "A fascinating look on the forces of nature that are able to move unimaginable tons of rocks. Find out how seemingly immovable landscape has transformed drastically through the incredible forces set free by earthquakes, vulcanos and water. This award-winning documentary traces the movement of the world's greatest mountain ranges throughout millions of years.", "45 min", false, "09.06.2026"] + ['Snowboarding for beginners - How to avoid falling on your face', 'assets/ui-examples/images/Image/ski_widescreen-thumbnail.jpg', "Bobby's School of Snowboarding Austria", null, 'This is the perfect start for anyone wanting to get on a snowboard. We talk the best gear and the best locations for a beginner. And no worries - it is not expensive: Renting equipment will work just fine. Then we end with some first exercises to get the stability needed to tackle your first slope', '23 min', false, '01.01.2026'], + ['The History of Bridges', 'assets/ui-examples/images/Image/sanfrancisco_widescreen-thumbnail.jpg', 'BBC England', $glyph_with_text, 'One of the most monumental achievements of human kind is the invention of bridges. Crossing streets, rivers and sometimes oceans became a huge pillar for our our modern infrastructure. This documentary looks at the different types of bridges and how they have been developed and engineered throughout different cultures and centuries', '90 min', true, '01.11.2026'], + ['Mountains through the ages - the formation of giants', 'assets/ui-examples/images/Image/mountains_widescreen-thumbnail.jpg', 'ARD/ZDF, Canal Plus', null, "A fascinating look on the forces of nature that are able to move unimaginable tons of rocks. Find out how seemingly immovable landscape has transformed drastically through the incredible forces set free by earthquakes, vulcanos and water. This award-winning documentary traces the movement of the world's greatest mountain ranges throughout millions of years.", '45 min', false, '11.10.2026'], + ['Snowboarding for beginners - How to avoid falling on your face', 'assets/ui-examples/images/Image/ski_widescreen-thumbnail.jpg', "Bobby's School of Snowboarding Austria", null, 'This is the perfect start for anyone wanting to get on a snowboard. We talk the best gear and the best locations for a beginner. And no worries - it is not expensive: Renting equipment will work just fine. Then we end with some first exercises to get the stability needed to tackle your first slope', '23 min', false, '28.02.2026'], + ['The History of Bridges', 'assets/ui-examples/images/Image/sanfrancisco_widescreen-thumbnail.jpg', 'BBC England', $glyph_with_text, 'One of the most monumental achievements of human kind is the invention of bridges. Crossing streets, rivers and sometimes oceans became a huge pillar for our our modern infrastructure. This documentary looks at the different types of bridges and how they have been developed and engineered throughout different cultures and centuries', '90 min', true, '15.12.2025'], + ['Mountains through the ages - the formation of giants', 'assets/ui-examples/images/Image/mountains_widescreen-thumbnail.jpg', 'ARD/ZDF, Canal Plus', null, "A fascinating look on the forces of nature that are able to move unimaginable tons of rocks. Find out how seemingly immovable landscape has transformed drastically through the incredible forces set free by earthquakes, vulcanos and water. This award-winning documentary traces the movement of the world's greatest mountain ranges throughout millions of years.", '45 min', false, '09.06.2026'] ]; - $data = new class ($card_data) implements DataRetrieval { - protected array $data = []; + $listing = $f->listing()->entity()->grid(new GridEntityRetrieval($card_data)); - public function __construct($card_data) - { - $this->data = $card_data; - } + return $renderer->render($listing); +} - public function getEntities( - Mapping $mapping, - ?Range $range, - ?array $additional_parameters - ): \Generator { - foreach ($this->data as $vid) { - yield $mapping->map($vid); +class GridEntityRetrieval implements EntityRetrieval +{ + public function __construct( + private readonly array $data, + ) { + } + + public function getEntities( + UIFactory $ui_factory, + Range $range, + Order $order, + mixed $additional_viewcontrol_data, + mixed $filter_data, + mixed $additional_parameters, + ): Generator { + foreach ($this->data as $index => $record) { + yield $this->mapRecord($ui_factory, $index, $record); + } + } + + public function getEntitiesByIds( + UIFactory $ui_factory, + Order $order, + array $entity_ids, + ): Generator { + foreach ($entity_ids as $entity_id) { + if (!isset($this->data[$entity_id])) { + continue; } + yield $this->mapRecord($ui_factory, $entity_id, $this->data[$entity_id]); } - }; + } + + protected function mapRecord(UIFactory $ui_factory, int|string $id, array $record): Entity + { + $glyph_user = $ui_factory->symbol()->glyph()->user() + ->withLabel('Created by'); + + $glyph_calendar = $ui_factory->symbol()->glyph()->calendar() + ->withLabel('Upload date'); + + $glyph_duration = $ui_factory->symbol()->glyph()->time() + ->withLabel('Duration'); + + list($title, $thumbnail_url, $creator, $availability, $description, $duration, $add_workflow, $date) = $record; + $managing_actions = [ + $ui_factory->button()->shy('Edit', '#'), + $ui_factory->button()->shy('Move', '#'), + ]; + $entity = $ui_factory->entity()->standard( + $id, + $ui_factory->link()->standard($title, ''), + $ui_factory->image()->responsive($thumbnail_url, $title)->withAction('#') + ) + ->withFeaturedProperties( + $ui_factory->listing()->property() + ->withProperty($glyph_user, $creator) + ) + ->withManagingActions(...$managing_actions) + ->withMainDetails( + $ui_factory->listing()->property() + ->withProperty($glyph_duration, $description, false) + ->withProperty($glyph_duration, $duration) + ->withProperty($glyph_calendar, $date) + ) + ->withPrioritizedReactions($ui_factory->button()->shy('Like', '#')->withSymbol($ui_factory->symbol()->glyph()->like())) + ; + if ($availability) { + $entity = $entity->withBlockingAvailabilityConditions( + $ui_factory->listing()->property() + ->withProperty('Status', $ui_factory->legacy()->content($availability), false) + ); + } + if ($add_workflow) { + $workflow_factory = $ui_factory->listing()->workflow(); + $dummy_step = $workflow_factory->step('', ''); + + $steps = [ + $workflow_factory->step('Upload video file', 'Upload an .mp4 file or start a recording.', '#') + ->withAvailability($dummy_step::NOT_ANYMORE)->withStatus($dummy_step::SUCCESSFULLY), + $workflow_factory->step('Cut video', 'Trim or remove parts of the video.', '#') + ->withAvailability($dummy_step::NOT_ANYMORE)->withStatus($dummy_step::NOT_STARTED), + $workflow_factory->step('Add subtitles', 'You must upload or generate subtitles for every video.', '#') + ->withAvailability($dummy_step::AVAILABLE)->withStatus($dummy_step::SUCCESSFULLY), + $workflow_factory->step('Publish', 'Set who can see this video.', '#') + ->withAvailability($dummy_step::AVAILABLE)->withStatus($dummy_step::NOT_STARTED), + ]; - $listing = $f->listing()->entity()->grid($record_to_entity) - ->withData($data); + $video_workflow = $workflow_factory->linear('Video Curation', $steps); - return $renderer->render($listing); + $entity = $entity->withWorkflow($video_workflow); + } + + return $entity; + } } diff --git a/components/ILIAS/UI/src/examples/Listing/Entity/Standard/base.php b/components/ILIAS/UI/src/examples/Listing/Entity/Standard/base.php index 3e445f019dcc..e7310cef38d6 100755 --- a/components/ILIAS/UI/src/examples/Listing/Entity/Standard/base.php +++ b/components/ILIAS/UI/src/examples/Listing/Entity/Standard/base.php @@ -20,12 +20,11 @@ namespace ILIAS\UI\Examples\Listing\Entity\Standard; -use ILIAS\UI\Factory as UIFactory; -use ILIAS\UI\Component\Listing\Entity\RecordToEntity; -use ILIAS\UI\Component\Listing\Entity\DataRetrieval; -use ILIAS\UI\Component\Entity\Entity; -use ILIAS\UI\Component\Listing\Entity\Mapping; +use Generator; +use ILIAS\Data\Order; use ILIAS\Data\Range; +use ILIAS\UI\Component\Entity\Entity; +use ILIAS\UI\Component\Entity\EntityRetrieval; /** * --- @@ -42,48 +41,63 @@ function base() $f = $DIC->ui()->factory(); $renderer = $DIC->ui()->renderer(); - $record_to_entity = new class () implements RecordToEntity { - public function map(UIFactory $ui_factory, mixed $record): Entity - { - list($abbreviation, $login, $email, $name, $last_seen, $active) = $record; - $avatar = $ui_factory->symbol()->avatar()->letter($abbreviation); - return $ui_factory->entity()->standard($name, $avatar) - ->withMainDetails( - $ui_factory->listing()->property() - ->withProperty('login', $login) - ->withProperty('mail', $email, false) - ) - ->withDetails( - $ui_factory->listing()->property() - ->withItems([ - ['last seen', $last_seen], - ['active', $active ? 'yes' : 'no'], - ]) - ); - } - }; + $listing = $f->listing()->entity()->standard(new DemoEntityRetrieval()); + + return $renderer->render($listing); +} - $data = new class () implements DataRetrieval { - protected $data = [ - ['jw', 'jimmywilson','jimmywilson@example.com', 'Jimmy Wilson', '2022-03-15 13:20:10', true], - ['eb', 'emilybrown','emilybrown@example.com','Emily Brown','2022-03-16 10:45:32', false], - ['ms', 'michaelscott','michaelscott@example.com','Michael Scott','2022-03-14 08:15:05', true], - ['kj', 'katiejones','katiejones@example.com','Katie Jones','2022-03-17 15:30:50',true] - ]; +class DemoEntityRetrieval implements EntityRetrieval +{ + protected array $data = [ + ['jw', 'jimmywilson', 'jimmywilson@example.com', 'Jimmy Wilson', '2022-03-15 13:20:10', true], + ['eb', 'emilybrown', 'emilybrown@example.com', 'Emily Brown', '2022-03-16 10:45:32', false], + ['ms', 'michaelscott', 'michaelscott@example.com', 'Michael Scott', '2022-03-14 08:15:05', true], + ['kj', 'katiejones', 'katiejones@example.com', 'Katie Jones', '2022-03-17 15:30:50', true], + ]; - public function getEntities( - Mapping $mapping, - ?Range $range, - ?array $additional_parameters - ): \Generator { - foreach ($this->data as $usr) { - yield $mapping->map($usr); + public function getEntities( + \ILIAS\UI\Factory $ui_factory, + Range $range, + Order $order, + mixed $additional_viewcontrol_data, + mixed $filter_data, + mixed $additional_parameters, + ): Generator { + foreach ($this->data as $index => $record) { + yield $this->mapRecord($ui_factory, $index, $record); + } + } + + public function getEntitiesByIds( + \ILIAS\UI\Factory $ui_factory, + Order $order, + array $entity_ids, + ): Generator { + foreach ($entity_ids as $entity_id) { + if (!isset($this->data[$entity_id])) { + continue; } + yield $this->mapRecord($ui_factory, $entity_id, $this->data[$entity_id]); } - }; + } - $listing = $f->listing()->entity()->standard($record_to_entity) - ->withData($data); + protected function mapRecord(\ILIAS\UI\Factory $ui_factory, int|string $id, array $record): Entity + { + [$abbreviation, $login, $email, $name, $last_seen, $active] = $record; + $avatar = $ui_factory->symbol()->avatar()->letter($abbreviation); - return $renderer->render($listing); + return $ui_factory->entity()->standard($id, $name, $avatar) + ->withMainDetails( + $ui_factory->listing()->property() + ->withProperty('login', $login) + ->withProperty('mail', $email, false) + ) + ->withDetails( + $ui_factory->listing()->property() + ->withItems([ + ['last seen', $last_seen], + ['active', $active ? 'yes' : 'no'], + ]) + ); + } } diff --git a/components/ILIAS/UI/src/examples/Prompt/Standard/file_upload.php b/components/ILIAS/UI/src/examples/Prompt/Standard/file_upload.php new file mode 100644 index 000000000000..ea651e851c69 --- /dev/null +++ b/components/ILIAS/UI/src/examples/Prompt/Standard/file_upload.php @@ -0,0 +1,113 @@ + + * Example showing how to use the Input Field File inside a Prompt (State) component. + * It includes minimal data processing. + * + * expected output: > + * ILIAS initially shows a button. When clicked, a dialog opens with a form visible. + * Inside the form there is one input for uploading files. When operated, files can + * be chosen using the browsers dialog. After choosing a file, one of the buttons at + * the bottom of the first dialog, can be used to submit the form. When pressed, files + * start uploading. After a while the form is submitted and the initial dialog is + * updated, while the page is not refreshed. Inside this new dialog state, an element + * appears that contains the submitted data. In this data the identifier of the uploaded + * file is be visible as an arbitrary string. + * --- + */ +function file_upload(): string +{ + global $DIC; + + $factory = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + $refinery = $DIC->refinery(); + $http = $DIC->http(); + $server_request = $DIC->http()->request(); + $get_request = $DIC->http()->wrapper()->query(); + $data_factory = new \ILIAS\Data\Factory(); + + $here_url = $data_factory->uri((string) $DIC->http()->request()->getUri()); + $here_url_builder = new URLBuilder($here_url); + + // will simulate a GET request to this example asking for a prompt state + [$form_get_url, $form_parameter] = $here_url_builder->acquireParameter([...explode(__NAMESPACE__, '\\'), __METHOD__], "get_form", "1"); + + // will simulate a POST request to this example that processes the file upload + [$form_post_url, $process_parameter] = $here_url_builder->acquireParameter([...explode(__NAMESPACE__, '\\'), __METHOD__], "process", "1"); + + // will simulate a shared function to create a Form that is accessible in all endpoints + $build_form = static fn() => $factory->input()->container()->form()->standard( + (string) $form_post_url->buildURI(), + [$factory->input()->field()->file(new ilUIDemoFileUploadHandlerGUI(), "Upload file")], + )->withSubmitLabel('Upload'); + + // simulates the POST endpoint for processing the file upload + if ($get_request->has($process_parameter->getName())) { + $form = $build_form()->withRequest($server_request); + $data_message_box = $factory->messageBox()->info( + "Files have been uploaded. You can verify the submitted data below:" . + "
" . print_r($form->getData(), true) . "
" + ); + $html = $renderer->renderAsync($factory->prompt()->state()->show($form, $data_message_box)); + $http->saveResponse( + $http->response() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody(Streams::ofString($html)) + ); + $http->sendResponse(); + $http->close(); + } + + // simulates the GET endpoint for rendering the prompt state + if ($get_request->has($form_parameter->getName())) { + $html = $renderer->renderAsync($factory->prompt()->state()->show($build_form())); + $http->saveResponse( + $http->response() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody(Streams::ofString($html)) + ); + $http->sendResponse(); + $http->close(); + } + + // create a Standard Prompt that requests the simulated GET endpoint + $prompt = $factory->prompt()->standard($form_get_url->buildURI())->withTitle('Upload file'); + + // create a Button that triggers the Standard Prompt + $button = $factory->button()->primary('Upload file', $prompt->getShowSignal()); + + return $renderer->render([$prompt, $button]); +} diff --git a/components/ILIAS/UI/src/examples/Prompt/Standard/multi_entity_confirmation.php b/components/ILIAS/UI/src/examples/Prompt/Standard/multi_entity_confirmation.php new file mode 100644 index 000000000000..173df99faba4 --- /dev/null +++ b/components/ILIAS/UI/src/examples/Prompt/Standard/multi_entity_confirmation.php @@ -0,0 +1,211 @@ + + * Example showing how the deletion of multiple Entity's are confirmed using a + * Prompt. It includes minimal data processing. + * + * expected output: > + * ILIAS initially shows a button. When clicked, a dialog opens with a Confirmation + * Message Box and an Entity Listing visible. At the bottom of the dialog a button + * can be found to confirm and perform the action. When clicked, the dialog is updated + * and a Success Message Box is shown. + * --- + */ +function multi_entity_confirmation(): string +{ + global $DIC; + + $factory = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + $refinery = $DIC->refinery(); + $http = $DIC->http(); + $server_request = $DIC->http()->request(); + $get_request = $DIC->http()->wrapper()->query(); + $data_factory = new \ILIAS\Data\Factory(); + + $pseudo_data = [ + 13 => ['jw', 'jimmywilson', 'jimmywilson@example.com', 'Jimmy Wilson', '2022-03-15 13:20:10', true], + 42 => ['eb', 'emilybrown', 'emilybrown@example.com', 'Emily Brown', '2022-03-16 10:45:32', false], + 67 => ['ms', 'michaelscott', 'michaelscott@example.com', 'Michael Scott', '2022-03-14 08:15:05', true], + 99 => ['kj', 'katiejones', 'katiejones@example.com', 'Katie Jones', '2022-03-17 15:30:50', true] + ]; + + $here_url = $data_factory->uri((string) $DIC->http()->request()->getUri()); + $here_url_builder = new URLBuilder($here_url); + + // will simulate a GET request to this example asking for a confirmation prompt state + // for the entities in $pseudo_data + [$confirmation_get_url, $entity_id_parameter] = $here_url_builder->acquireParameter([...explode(__NAMESPACE__, '\\'), __METHOD__], "to_process"); + $confirmation_get_url = $confirmation_get_url->withParameter($entity_id_parameter, array_keys($pseudo_data)); + + // will simulate a POST request to this example that processes the entities in $pseudo_data + [$confirmation_post_url, $process_parameter] = $here_url_builder->acquireParameter([...explode(__NAMESPACE__, '\\'), __METHOD__], "process", "1"); + + // will simulate a shared function to create a Form that is accessible in all endpoints + $build_form = static fn(?array $entity_ids = null) => $factory->input()->container()->form()->standard( + (string) $confirmation_post_url->buildURI(), + [$entity_id_parameter->getName() => $factory->input()->field()->hiddenList()->withValue($entity_ids ?? [])], + )->withSubmitLabel('Delete'); + + // simulates the POST endpoint for processing the confirmed entities + if ($get_request->has($process_parameter->getName())) { + // create the same Form that carried the entity ids as Hidden List Field + $form = $build_form(); + + // provide the Form with the current request and fetch the entity ids from its body + $confirmed_entity_ids = $form->withRequest($server_request)->getData()[$entity_id_parameter->getName()] ?? null; + + // here you should process the $confirmed_entity_ids as you see fit and + // validate them beforehand, as we must not trust clients + foreach ($confirmed_entity_ids as $entity_id) { + if ($entity_id) { + } + } + + // create a Success Message Box asking the proper question and make + // sure to integrate the amount of entities as a number + $message_box = $factory->messageBox()->success( + sprintf("Successfully deleted %d entities.", count($confirmed_entity_ids)), + ); + + // create a Show Prompt State to instruct the client to render the Message Box + $prompt_state = $factory->prompt()->state()->show($message_box); + + // render the Prompt State and send it asynchronously to the client. + $html = $renderer->renderAsync($prompt_state); + $http->saveResponse( + $http->response() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody(Streams::ofString($html)) + ); + $http->sendResponse(); + $http->close(); + } + + // simulates the GET endpoint for rendering the prompt state + if ($get_request->has($entity_id_parameter->getName())) { + // retrieve requested entity ids from GET request + $requested_entity_ids = $get_request->retrieve( + $entity_id_parameter->getName(), + $refinery->kindlyTo()->listOf($refinery->kindlyTo()->int()) + ); + + // create a Form that carries the entity ids as Hidden List Field + $form = $build_form($requested_entity_ids); + + // create a Entity Listing that visualises the subset entities + $listing = $factory->listing()->entity()->standard(new ExampleEntityRetrieval($pseudo_data))->withSubset( + $requested_entity_ids + ); + + // create a Confirmation Message Box asking the proper question and make + // sure to integrate the amount of entities as a number + $message_box = $factory->messageBox()->confirmation( + sprintf("Do you want to delete %d entities?", count($requested_entity_ids)), + ); + + // create a Show Prompt State to instruct the client to render all of above and + // make sure $form is the primary prompt content + $prompt_state = $factory->prompt()->state()->show($form, $message_box, $listing); + + // render the Prompt State and send it asynchronously to the client. + $html = $renderer->renderAsync($prompt_state); + $http->saveResponse( + $http->response() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody(Streams::ofString($html)) + ); + $http->sendResponse(); + $http->close(); + } + + // create a Standard Prompt that requests the simulated GET endpoint + $prompt = $factory->prompt()->standard($confirmation_get_url->buildURI())->withTitle('Confirm deletion'); + + // create a Button that triggers the Standard Prompt + $button = $factory->button()->primary('Delete entities', $prompt->getShowSignal()); + + return $renderer->render([$button, $prompt]); +} + +/** @noinspection AutoloadingIssuesInspection */ +class ExampleEntityRetrieval implements EntityRetrieval +{ + public function __construct(protected array $dataset) + { + } + + public function getEntities( + \ILIAS\UI\Factory $ui_factory, + Range $range, + Order $order, + mixed $additional_viewcontrol_data, + mixed $filter_data, + mixed $additional_parameters, + ): Generator { + foreach (array_keys($this->dataset) as $entity_id) { + yield $this->createEntity($ui_factory, $entity_id); + } + } + + public function getEntitiesByIds( + \ILIAS\UI\Factory $ui_factory, + Order $order, + array $entity_ids, + ): Generator { + foreach ($entity_ids as $entity_id) { + yield $this->createEntity($ui_factory, $entity_id); + } + } + + protected function createEntity(\ILIAS\UI\Factory $ui_factory, string|int $entity_id): Entity + { + if (!isset($this->dataset[$entity_id])) { + return $ui_factory->entity()->standard($entity_id, 'Unknown', 'Unknown'); + } + + [$initials, $login, $email, $name, $last_seen, $active] = $this->dataset[$entity_id]; + + return $ui_factory + ->entity() + ->standard($entity_id, $name, $ui_factory->symbol()->avatar()->letter($initials)) + ->withMainDetails( + $ui_factory + ->listing() + ->property() + ->withProperty('login', $login) + ->withProperty('email', $email) + ); + } +} diff --git a/components/ILIAS/UI/src/examples/Prompt/Standard/parameters.php b/components/ILIAS/UI/src/examples/Prompt/Standard/parameters.php index 894c592185b4..8d802625d072 100644 --- a/components/ILIAS/UI/src/examples/Prompt/Standard/parameters.php +++ b/components/ILIAS/UI/src/examples/Prompt/Standard/parameters.php @@ -20,7 +20,6 @@ namespace ILIAS\UI\examples\Prompt\Standard; -use ILIAS\UI\Component\Prompt\PromptContent; use ILIAS\UI\URLBuilder; /** @@ -97,8 +96,8 @@ function parameters() ->buildURI()->__toString(); $back = $factory->button()->standard('back', '#')->withOnLoadCode( fn($id) => "$('#$id').on('click', (e)=> { - let promptId = e.target.closest('.il-prompt').id; - il.UI.prompt.get(promptId).show('$back_uri'); + let promptId = e.target.closest('.c-prompt').id; + il.UI.Prompt.get(promptId).loadState('$back_uri'); });" ); $prompt_content = $factory->messageBox()->info((string) $amount) diff --git a/components/ILIAS/UI/src/examples/Prompt/Standard/single_entity_confirmation.php b/components/ILIAS/UI/src/examples/Prompt/Standard/single_entity_confirmation.php new file mode 100644 index 000000000000..84b76e9eb7d9 --- /dev/null +++ b/components/ILIAS/UI/src/examples/Prompt/Standard/single_entity_confirmation.php @@ -0,0 +1,174 @@ + + * Example showing how the deletion of a single Entity is confirmed using a Prompt. + * It includes minimal data processing. + * + * expected output: > + * ILIAS initially shows a button. When clicked, a dialog opens with a Confirmation + * Message Box and a Form visible. Inside the Form there is one Input Field which + * must be used to enter the Entity's name and confirm the action. At the bottom of + * the dialog a button can be found to submit the form. When clicked, the dialog is + * updated and either a Success Message Box is shown, or the Form with all errors. + * --- + */ +function single_entity_confirmation(): string +{ + global $DIC; + + $factory = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + $refinery = $DIC->refinery(); + $http = $DIC->http(); + $server_request = $DIC->http()->request(); + $get_request = $DIC->http()->wrapper()->query(); + $data_factory = new \ILIAS\Data\Factory(); + + $pseudo_data = [ + 67 => ['ms', 'michaelscott', 'michaelscott@example.com', 'Michael Scott', '2022-03-14 08:15:05', true], + ]; + + $here_url = $data_factory->uri((string) $DIC->http()->request()->getUri()); + $here_url_builder = new URLBuilder($here_url); + + // will simulate a GET request to this example asking for a confirmation prompt state + // for the entity in $pseudo_data + [$confirmation_get_url, $entity_id_parameter] = $here_url_builder->acquireParameter([...explode(__NAMESPACE__, '\\'), __METHOD__], "to_process"); + $confirmation_get_url = $confirmation_get_url->withParameter($entity_id_parameter, (string) array_key_first($pseudo_data)); + + // will simulate a POST request to this example that processes the entity in $pseudo_data. + // this example requires the entity id in both url and body of the POST request. + [$confirmation_post_url, $process_parameter] = $confirmation_get_url->acquireParameter([...explode(__NAMESPACE__, '\\'), __METHOD__], "process", "1"); + + // will simulate a shared function to create a Form that is accessible in all endpoints. + // notice how $entity_id is mandatory here, because we perform validation. + $build_form = static fn(string $entity_id) => $factory->input()->container()->form()->standard( + (string) $confirmation_post_url->buildURI(), + [ + $entity_id_parameter->getName() => $factory->input()->field()->hidden()->withValue($entity_id), + "entity_text_identifier" => $factory + ->input()->field()->text( + "Enter entity name", + sprintf("Type '%s' to confirm deletion.", $pseudo_data[$entity_id][3]) + ) + ->withRequired(true, $refinery->logical()->sequential([ + $refinery->string()->hasMinLength(1), + $refinery->custom()->constraint( + static fn(string $value) => ($value === $pseudo_data[$entity_id][3]), + sprintf("Input must match '%s' exactly.", $pseudo_data[$entity_id][3]), + ) + ])) + ], + )->withSubmitLabel('Delete'); + + // simulates the POST endpoint for processing the confirmed entities + if ($get_request->has($process_parameter->getName()) && $get_request->has($entity_id_parameter->getName())) { + // retrieve requested entity id from GET request + $requested_entity_id = $get_request->retrieve($entity_id_parameter->getName(), $refinery->kindlyTo()->string()); + + // create the same Form that carried the entity id as Hidden Field + $form = $build_form($requested_entity_id); + + // provide the Form with the current request and fetch the entity ids from its body + $form = $form->withRequest($server_request); + $data = $form->getData(); + $confirmed_entity_id = $data[$entity_id_parameter->getName()] ?? null; + $confirmed_entity_text = $data['entity_text_identifier'] ?? null; + + if (null === $confirmed_entity_id || $confirmed_entity_text !== $pseudo_data[$confirmed_entity_id][3]) { + // create a Show Prompt State that shows the validated form + $prompt_state = $factory->prompt()->state()->show($form); + } else { + // here you should process the $confirmed_entity_ids as you see fit and + // validate them beforehand, as we must not trust clients + // ... + + // create a Success Message Box asking the proper question and make + // sure to integrate the amount of entities as a number + $message_box = $factory->messageBox()->success( + sprintf("Successfully deleted %s.", $pseudo_data[$confirmed_entity_id][3]), + ); + + // create a Show Prompt State to instruct the client to render the Message Box + $prompt_state = $factory->prompt()->state()->show($message_box); + } + + // render the Prompt State and send it asynchronously to the client. + $html = $renderer->renderAsync($prompt_state); + $http->saveResponse( + $http->response() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody(Streams::ofString($html)) + ); + $http->sendResponse(); + $http->close(); + } + + // simulates the GET endpoint for rendering the prompt state + if ($get_request->has($entity_id_parameter->getName())) { + // retrieve requested entity id from GET request + $requested_entity_id = $get_request->retrieve($entity_id_parameter->getName(), $refinery->kindlyTo()->string()); + + // create a Form that carries the entity id as Hidden Field + $form = $build_form($requested_entity_id); + + // create a Confirmation Message Box asking the proper question and make + // sure to integrate the entity type and a textual identifier + $message_box = $factory->messageBox()->confirmation( + sprintf("Do you want to delete Entity %s?", $pseudo_data[$requested_entity_id][3]), + ); + + // create a Show Prompt State to instruct the client to render all of above and + // make sure $form is the primary prompt content + $prompt_state = $factory->prompt()->state()->show($form, $message_box); + + // render the Prompt State and send it asynchronously to the client. + $html = $renderer->renderAsync($prompt_state); + $http->saveResponse( + $http->response() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody(Streams::ofString($html)) + ); + $http->sendResponse(); + $http->close(); + } + + // create a Standard Prompt that requests the simulated GET endpoint + $prompt = $factory->prompt()->standard($confirmation_get_url->buildURI())->withTitle('Confirm deletion'); + + // create a Button that triggers the Standard Prompt + $button = $factory->button()->primary('Delete entity', $prompt->getShowSignal()); + + return $renderer->render([$button, $prompt]); +} diff --git a/components/ILIAS/UI/src/templates/default/Input/tpl.hidden_list.html b/components/ILIAS/UI/src/templates/default/Input/tpl.hidden_list.html new file mode 100644 index 000000000000..e8696837547e --- /dev/null +++ b/components/ILIAS/UI/src/templates/default/Input/tpl.hidden_list.html @@ -0,0 +1,3 @@ +
+ {HIDDEN_INPUTS} +
diff --git a/components/ILIAS/UI/src/templates/default/Listing/tpl.entitylistinggrid.html b/components/ILIAS/UI/src/templates/default/Listing/tpl.entity_grid.html similarity index 53% rename from components/ILIAS/UI/src/templates/default/Listing/tpl.entitylistinggrid.html rename to components/ILIAS/UI/src/templates/default/Listing/tpl.entity_grid.html index 18f073d65d9b..a357f4628545 100644 --- a/components/ILIAS/UI/src/templates/default/Listing/tpl.entitylistinggrid.html +++ b/components/ILIAS/UI/src/templates/default/Listing/tpl.entity_grid.html @@ -1,4 +1,4 @@ -
    +
    • {ENTITY}
    • diff --git a/components/ILIAS/UI/src/templates/default/Listing/tpl.entity_standard.html b/components/ILIAS/UI/src/templates/default/Listing/tpl.entity_standard.html new file mode 100755 index 000000000000..d1f8814e557f --- /dev/null +++ b/components/ILIAS/UI/src/templates/default/Listing/tpl.entity_standard.html @@ -0,0 +1,7 @@ +
        + +
      • + {ENTITY} +
      • + +
      diff --git a/components/ILIAS/UI/src/templates/default/Listing/tpl.entitylisting.html b/components/ILIAS/UI/src/templates/default/Listing/tpl.entitylisting.html deleted file mode 100755 index d3023bd3949f..000000000000 --- a/components/ILIAS/UI/src/templates/default/Listing/tpl.entitylisting.html +++ /dev/null @@ -1,7 +0,0 @@ -
        - -
      • - {ENTITY} -
      • - -
      diff --git a/components/ILIAS/UI/src/templates/default/Listing/tpl.propertylisting.html b/components/ILIAS/UI/src/templates/default/Listing/tpl.property.html similarity index 100% rename from components/ILIAS/UI/src/templates/default/Listing/tpl.propertylisting.html rename to components/ILIAS/UI/src/templates/default/Listing/tpl.property.html diff --git a/components/ILIAS/UI/src/templates/default/Prompt/tpl.prompt.html b/components/ILIAS/UI/src/templates/default/Prompt/tpl.prompt.html index 91029cf0eb9c..19f3dbdabf97 100644 --- a/components/ILIAS/UI/src/templates/default/Prompt/tpl.prompt.html +++ b/components/ILIAS/UI/src/templates/default/Prompt/tpl.prompt.html @@ -1,17 +1,22 @@ -
      +
      -
      -
      +
      + - {TITLE} + {TITLE}

      -
      +

      -
      +
      +
      + +
      +
      +
      -
      -
      \ No newline at end of file + +
      diff --git a/components/ILIAS/UI/src/templates/default/Prompt/tpl.prompt_state.html b/components/ILIAS/UI/src/templates/default/Prompt/tpl.prompt_state.html new file mode 100644 index 000000000000..8a9504fb6933 --- /dev/null +++ b/components/ILIAS/UI/src/templates/default/Prompt/tpl.prompt_state.html @@ -0,0 +1,13 @@ +
      +
      {TITLE}
      +
      + + {SECONDARY_CONTENT_TOP} + + {PRIMARY_CONTENT} + + {SECONDARY_CONTENT_BOTTOM} + +
      +
      {BUTTONS}
      +
      diff --git a/components/ILIAS/UI/src/templates/default/Prompt/tpl.promptstate.html b/components/ILIAS/UI/src/templates/default/Prompt/tpl.promptstate.html deleted file mode 100644 index 93bb1029be4b..000000000000 --- a/components/ILIAS/UI/src/templates/default/Prompt/tpl.promptstate.html +++ /dev/null @@ -1,25 +0,0 @@ -
      -
      - {TITLE} -
      -
      - - {TOP_SECONDARY_CONTENT} - - {PRIMARY_CONTENT} - - {BOTTOM_SECONDARY_CONTENT} - -
      -
      - {BUTTONS} -
      -
      - {COMMAND} -
      -
      - - {KEY} - -
      -
      diff --git a/components/ILIAS/UI/tests/Base.php b/components/ILIAS/UI/tests/Base.php index 0412bff2ac42..1966f681815b 100755 --- a/components/ILIAS/UI/tests/Base.php +++ b/components/ILIAS/UI/tests/Base.php @@ -46,6 +46,7 @@ use ILIAS\UI\Implementation\Component\Input\UploadLimitResolver; use ILIAS\UI\Implementation\Component\MessageBox\MessageBoxRendererFactory; use ILIAS\UI\Implementation\Component\Input\Container\Form\FormRendererFactory; +use PHPUnit\Framework\MockObject\Stub; class ilIndependentTemplateFactory implements TemplateFactory { @@ -224,6 +225,11 @@ public function getOnLoadCodeAsync(): string { return ""; } + + public function clearOnLoadCodeAsync(): void + { + $this->on_load_code = []; + } } class TestDefaultRenderer extends DefaultRenderer @@ -396,14 +402,16 @@ public function getUploadLimitResolver(): UploadLimitResolver } /** - * @param Component[] $with_stub_renderings - * @param Component[] $with_additional_contexts + * @param Component&Stub[] $with_stub_renderings + * @param Component&Stub[] $with_additional_contexts */ public function getDefaultRenderer( ?JavaScriptBinding $js_binding = null, array $with_stub_renderings = [], array $with_additional_contexts = [], ): TestDefaultRenderer { + // we need a real context matcher instance for testing context rendering + $real_context_matcher = new Render\ContextMatcher(); $ui_factory = $this->getUIFactory(); $tpl_factory = $this->getTemplateFactory(); $resource_registry = $this->getResourceRegistry(); @@ -421,6 +429,7 @@ public function getDefaultRenderer( $resource_registry, new Render\FSLoader( new DefaultRendererFactory( + $real_context_matcher, $ui_factory, $tpl_factory, $lng, @@ -431,6 +440,7 @@ public function getDefaultRenderer( $this->getUploadLimitResolver() ), new ButtonRendererFactory( + $real_context_matcher, $ui_factory, $tpl_factory, $lng, @@ -441,6 +451,7 @@ public function getDefaultRenderer( $this->getUploadLimitResolver() ), new FieldRendererFactory( + $real_context_matcher, $ui_factory, $tpl_factory, $lng, @@ -451,6 +462,7 @@ public function getDefaultRenderer( $this->getUploadLimitResolver() ), new MessageBoxRendererFactory( + $real_context_matcher, $ui_factory, $tpl_factory, $lng, @@ -461,6 +473,7 @@ public function getDefaultRenderer( $this->getUploadLimitResolver() ), new FormRendererFactory( + $real_context_matcher, $ui_factory, $tpl_factory, $lng, @@ -471,6 +484,18 @@ public function getDefaultRenderer( $this->getUploadLimitResolver() ), new I\Menu\MenuRendererFactory( + $real_context_matcher, + $ui_factory, + $tpl_factory, + $lng, + $js_binding, + $image_path_resolver, + $data_factory, + $help_text_retriever, + $this->getUploadLimitResolver(), + ), + new I\Entity\EntityRendererFactory( + $real_context_matcher, $ui_factory, $tpl_factory, $lng, @@ -481,6 +506,7 @@ public function getDefaultRenderer( $this->getUploadLimitResolver(), ), new I\Listing\ListingRendererFactory( + $real_context_matcher, $ui_factory, $tpl_factory, $lng, diff --git a/components/ILIAS/UI/tests/Client/Prompt/Prompt.js b/components/ILIAS/UI/tests/Client/Prompt/Prompt.js new file mode 100644 index 000000000000..30ea3344ed86 --- /dev/null +++ b/components/ILIAS/UI/tests/Client/Prompt/Prompt.js @@ -0,0 +1,395 @@ +/** + * 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 + */ + +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import { strict } from 'node:assert/strict'; +import * as assert from 'node:assert'; +import * as PROMPT from '../../../resources/js/Prompt/src/constants.js'; +import Prompt from '../../../resources/js/Prompt/src/Prompt.js'; + +const BASE_URI = 'https://example.com/base/'; + +// fake DOM classes, the class under test only uses instanceof checks on these +class FakeForm { + constructor({ method = 'get', action = 'https://example.com/submit', entries = [] } = {}) { + this.method = method; + this.action = action; + this.entries = entries; + } +} + +class FakeAnchor { + constructor({ href = 'https://example.com/link', target = '_self' } = {}) { + this.href = href; + this.target = target; + } +} + +class FakeFormData { + constructor(form) { + this.form = form; + this.pairs = form.entries ?? []; + } + + [Symbol.iterator]() { + return this.pairs[Symbol.iterator](); + } +} + +function createDialog() { + const listeners = {}; + const location = { baseURI: BASE_URI, replace: mock.fn() }; + const view = { HTMLFormElement: FakeForm, HTMLAnchorElement: FakeAnchor, location }; + return { + ownerDocument: { defaultView: view }, + location, + listeners, + addEventListener: mock.fn((type, handler) => { + listeners[type] = handler; + }), + showModal: mock.fn(), + close: mock.fn(), + }; +} + +function createEvent(target, submitterAttributes = null) { + const event = { + target, + defaultPrevented: false, + submitter: submitterAttributes === null + ? undefined + : { getAttribute: (name) => submitterAttributes[name] ?? null }, + preventDefault: mock.fn(() => { + event.defaultPrevented = true; + }), + }; + return event; +} + +const flush = () => new Promise((resolve) => { setImmediate(resolve); }); + +describe('Prompt', () => { + let dialog; + let renderer; + let loader; + let errorElements; + let prompt; + let originalFormData; + + const showState = { + instruction: PROMPT.PROMPT_STATE_INSTRUCTION_SHOW, + title: 'Title', + contents: ['content'], + buttons: ['button'], + scripts: ['script'], + }; + + beforeEach(() => { + originalFormData = globalThis.FormData; + globalThis.FormData = FakeFormData; + + dialog = createDialog(); + renderer = { render: mock.fn() }; + loader = { load: mock.fn(async () => showState) }; + errorElements = [{ id: 'error' }]; + prompt = new Prompt(renderer, loader, errorElements, dialog); + }); + + afterEach(() => { + globalThis.FormData = originalFormData; + mock.restoreAll(); + }); + + describe('constructor', () => { + it('registers submit and click listeners on the dialog', () => { + assert.equal(dialog.addEventListener.mock.callCount(), 2); + assert.equal(typeof dialog.listeners.submit, 'function'); + assert.equal(typeof dialog.listeners.click, 'function'); + }); + }); + + describe('show() / close()', () => { + it('opens the dialog modally', () => { + prompt.show(); + assert.equal(dialog.showModal.mock.callCount(), 1); + }); + + it('closes the dialog', () => { + prompt.close(); + assert.equal(dialog.close.mock.callCount(), 1); + }); + }); + + describe('loadState()', () => { + it('resolves a relative url string against the base uri and passes the request', async () => { + const request = { method: 'POST' }; + await prompt.loadState('foo/bar?x=1', request); + + assert.equal(loader.load.mock.callCount(), 1); + const [url, passedRequest] = loader.load.mock.calls[0].arguments; + assert.ok(url instanceof URL); + assert.equal(url.href, 'https://example.com/base/foo/bar?x=1'); + assert.equal(passedRequest, request); + }); + + it('accepts URL objects', async () => { + await prompt.loadState(new URL('https://other.example.org/x')); + const [url, request] = loader.load.mock.calls[0].arguments; + assert.equal(url.href, 'https://other.example.org/x'); + assert.deepEqual(request, {}); + }); + + it('renders and shows the prompt on a "show" instruction', async () => { + await prompt.loadState('x'); + + assert.equal(renderer.render.mock.callCount(), 1); + assert.deepEqual(renderer.render.mock.calls[0].arguments, [ + 'Title', ['content'], ['button'], ['script'], + ]); + assert.equal(dialog.showModal.mock.callCount(), 1); + }); + + it('replaces the location on a "redirect" instruction', async () => { + loader.load = mock.fn(async () => ({ + instruction: PROMPT.PROMPT_STATE_INSTRUCTION_REDIRECT, + redirect: '/target?a=b', + })); + + await prompt.loadState('x'); + + assert.equal(dialog.location.replace.mock.callCount(), 1); + const arg = dialog.location.replace.mock.calls[0].arguments[0]; + assert.ok(arg instanceof URL); + assert.equal(arg.href, 'https://example.com/target?a=b'); + assert.equal(renderer.render.mock.callCount(), 0); + }); + + it('closes the dialog on a "close" instruction', async () => { + loader.load = mock.fn(async () => ({ instruction: PROMPT.PROMPT_STATE_INSTRUCTION_CLOSE })); + + await prompt.loadState('x'); + + assert.equal(dialog.close.mock.callCount(), 1); + assert.equal(dialog.showModal.mock.callCount(), 0); + assert.equal(renderer.render.mock.callCount(), 0); + }); + + it('rejects and renders the error elements on an unknown instruction', async () => { + loader.load = mock.fn(async () => ({ instruction: 'bogus' })); + + await assert.rejects( + prompt.loadState('x'), + { message: "Could not process state instruction: 'bogus'" }, + ); + + assert.equal(renderer.render.mock.callCount(), 1); + assert.deepEqual(renderer.render.mock.calls[0].arguments, [null, errorElements, []]); + assert.equal(dialog.showModal.mock.callCount(), 1); + }); + + it('rethrows loader errors after rendering the error elements', async () => { + const error = new Error('network down'); + loader.load = mock.fn(async () => { throw error; }); + + await assert.rejects(prompt.loadState('x'), (thrown) => thrown === error); + + assert.deepEqual(renderer.render.mock.calls[0].arguments, [null, errorElements, []]); + assert.equal(dialog.showModal.mock.callCount(), 1); + }); + + it('ignores calls while a state is still loading', async () => { + let resolveLoad; + loader.load = mock.fn(() => new Promise((resolve) => { resolveLoad = resolve; })); + + const first = prompt.loadState('first'); + await prompt.loadState('second'); + + assert.equal(loader.load.mock.callCount(), 1); + + resolveLoad(showState); + await first; + assert.equal(renderer.render.mock.callCount(), 1); + }); + + it('allows loading again after a successful load', async () => { + await prompt.loadState('one'); + await prompt.loadState('two'); + assert.equal(loader.load.mock.callCount(), 2); + }); + + it('allows loading again after a failed load', async () => { + loader.load = mock.fn(async () => { throw new Error('fail'); }); + await assert.rejects(prompt.loadState('one')); + + loader.load = mock.fn(async () => showState); + await prompt.loadState('two'); + + assert.equal(loader.load.mock.callCount(), 1); + assert.equal(dialog.showModal.mock.callCount(), 2); + }); + }); + + describe('form submit interception', () => { + const submit = (event) => dialog.listeners.submit(event); + + it('submits POST forms via loadState with method and form data', async () => { + const form = new FakeForm({ method: 'post', action: 'https://example.com/save' }); + const event = createEvent(form); + + submit(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 1); + assert.equal(loader.load.mock.callCount(), 1); + const [url, request] = loader.load.mock.calls[0].arguments; + assert.equal(url.href, 'https://example.com/save'); + assert.equal(request.method, 'POST'); + assert.ok(request.body instanceof FakeFormData); + assert.equal(request.body.form, form); + }); + + it('converts GET form data into query parameters', async () => { + const form = new FakeForm({ + method: 'get', + action: 'https://example.com/search?old=1', + entries: [['q', 'a b'], ['page', '2']], + }); + const event = createEvent(form); + + submit(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 1); + const [url, request] = loader.load.mock.calls[0].arguments; + assert.equal(url.href, 'https://example.com/search?q=a+b&page=2'); + assert.deepEqual(request, {}); + }); + + it('prefers the submitter formmethod and formaction', async () => { + const form = new FakeForm({ method: 'get', action: 'https://example.com/default' }); + const event = createEvent(form, { formmethod: 'post', formaction: 'other' }); + + submit(event); + await flush(); + + const [url, request] = loader.load.mock.calls[0].arguments; + assert.equal(url.href, 'https://example.com/base/other'); + assert.equal(request.method, 'POST'); + }); + + it('falls back to form attributes when the submitter has none', async () => { + const form = new FakeForm({ method: 'post', action: 'https://example.com/fallback' }); + const event = createEvent(form, {}); + + submit(event); + await flush(); + + const [url, request] = loader.load.mock.calls[0].arguments; + assert.equal(url.href, 'https://example.com/fallback'); + assert.equal(request.method, 'POST'); + }); + + it('ignores forms with method "dialog"', async () => { + const event = createEvent(new FakeForm({ method: 'dialog' })); + + submit(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 0); + assert.equal(loader.load.mock.callCount(), 0); + }); + + it('ignores submitters that override the method with "dialog"', async () => { + const event = createEvent(new FakeForm({ method: 'post' }), { formmethod: 'dialog' }); + + submit(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 0); + assert.equal(loader.load.mock.callCount(), 0); + }); + + it('ignores events that are already default-prevented', async () => { + const event = createEvent(new FakeForm({ method: 'post' })); + event.defaultPrevented = true; + + submit(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 0); + assert.equal(loader.load.mock.callCount(), 0); + }); + + it('ignores events whose target is not a form', async () => { + const event = createEvent({}); + + submit(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 0); + assert.equal(loader.load.mock.callCount(), 0); + }); + }); + + describe('anchor click interception', () => { + const click = (event) => dialog.listeners.click(event); + + it('loads the state of anchors targeting _self', async () => { + const event = createEvent(new FakeAnchor({ href: 'https://example.com/next?x=1' })); + + click(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 1); + assert.equal(loader.load.mock.callCount(), 1); + const [url, request] = loader.load.mock.calls[0].arguments; + assert.equal(url.href, 'https://example.com/next?x=1'); + assert.deepEqual(request, {}); + }); + + it('ignores anchors with another target', async () => { + const event = createEvent(new FakeAnchor({ target: '_blank' })); + + click(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 0); + assert.equal(loader.load.mock.callCount(), 0); + }); + + it('ignores events that are already default-prevented', async () => { + const event = createEvent(new FakeAnchor()); + event.defaultPrevented = true; + + click(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 0); + assert.equal(loader.load.mock.callCount(), 0); + }); + + it('ignores events whose target is not an anchor', async () => { + const event = createEvent({}); + + click(event); + await flush(); + + assert.equal(event.preventDefault.mock.callCount(), 0); + assert.equal(loader.load.mock.callCount(), 0); + }); + }); +}); diff --git a/components/ILIAS/UI/tests/Client/Prompt/PromptRenderer.js b/components/ILIAS/UI/tests/Client/Prompt/PromptRenderer.js new file mode 100644 index 000000000000..8dc39bb50716 --- /dev/null +++ b/components/ILIAS/UI/tests/Client/Prompt/PromptRenderer.js @@ -0,0 +1,221 @@ +/** + * 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 + */ + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import PromptRenderer from '../../../resources/js/Prompt/src/PromptRenderer.js'; + +// minimal fake element that tracks its children like the real DOM methods would +function createElement(name, textContent = '') { + return { + name, + textContent, + children: [], + replaceChildren(...nodes) { + this.children = nodes; + }, + append(...nodes) { + this.children.push(...nodes); + }, + }; +} + +const createScript = (source) => ({ outerHTML: `` }); + +describe('PromptRenderer', () => { + let closeDialogElement; + let titleElement; + let contentElement; + let buttonsElement; + let scriptsElement; + let renderer; + + beforeEach(() => { + closeDialogElement = createElement('close'); + titleElement = createElement('title', 'Old title'); + contentElement = createElement('content'); + buttonsElement = createElement('buttons'); + scriptsElement = createElement('scripts'); + renderer = new PromptRenderer( + closeDialogElement, + titleElement, + contentElement, + buttonsElement, + scriptsElement, + ); + }); + + describe('render() title', () => { + it('updates the title if one is provided', () => { + renderer.render('New title', [], []); + assert.equal(titleElement.textContent, 'New title'); + }); + + it('keeps the current title if null is provided', () => { + renderer.render(null, [], []); + assert.equal(titleElement.textContent, 'Old title'); + }); + + it('keeps the current title if an empty string is provided', () => { + renderer.render('', [], []); + assert.equal(titleElement.textContent, 'Old title'); + }); + + it('keeps the current title if a blank string is provided', () => { + renderer.render(' \n\t', [], []); + assert.equal(titleElement.textContent, 'Old title'); + }); + }); + + describe('render() content', () => { + it('sets the given contents', () => { + const first = createElement('first'); + const second = createElement('second'); + + renderer.render(null, [first, second], []); + + assert.deepEqual(contentElement.children, [first, second]); + }); + + it('replaces previously rendered contents', () => { + const old = createElement('old'); + const next = createElement('next'); + + renderer.render(null, [old], []); + renderer.render(null, [next], []); + + assert.deepEqual(contentElement.children, [next]); + }); + + it('clears the contents if none are given', () => { + renderer.render(null, [createElement('old')], []); + renderer.render(null, [], []); + + assert.deepEqual(contentElement.children, []); + }); + }); + + describe('render() buttons', () => { + it('renders the buttons followed by the close element', () => { + const first = createElement('first'); + const second = createElement('second'); + + renderer.render(null, [], [first, second]); + + assert.deepEqual(buttonsElement.children, [first, second, closeDialogElement]); + }); + + it('renders only the close element if there are no buttons', () => { + renderer.render(null, [], []); + + assert.deepEqual(buttonsElement.children, [closeDialogElement]); + }); + + it('replaces previously rendered buttons and keeps the close element last', () => { + const old = createElement('old'); + const next = createElement('next'); + + renderer.render(null, [], [old]); + renderer.render(null, [], [next]); + + assert.deepEqual(buttonsElement.children, [next, closeDialogElement]); + }); + }); + + describe('render() scripts', () => { + it('does not touch the scripts element if no scripts are given', () => { + renderer.render(null, [], []); + + assert.deepEqual(scriptsElement.children, []); + }); + + it('does not touch the scripts element for an empty scripts array', () => { + renderer.render(null, [], [], []); + + assert.deepEqual(scriptsElement.children, []); + }); + + it('appends the given scripts', () => { + const scripts = [createScript('a'), createScript('b')]; + + renderer.render(null, [], [], scripts); + + assert.deepEqual(scriptsElement.children, scripts); + }); + + it('does not append the same scripts twice', () => { + const scripts = [createScript('a'), createScript('b')]; + + renderer.render(null, [], [], scripts); + renderer.render(null, [], [], scripts); + + assert.deepEqual(scriptsElement.children, scripts); + }); + + it('identifies scripts by their markup, not by instance', () => { + const first = [createScript('a')]; + const second = [createScript('a')]; + + renderer.render(null, [], [], first); + renderer.render(null, [], [], second); + + assert.deepEqual(scriptsElement.children, first); + }); + + it('appends a different set of scripts additionally', () => { + const first = [createScript('a')]; + const second = [createScript('b')]; + + renderer.render(null, [], [], first); + renderer.render(null, [], [], second); + + assert.deepEqual(scriptsElement.children, [...first, ...second]); + }); + + it('still renders title, contents and buttons when scripts are skipped', () => { + const scripts = [createScript('a')]; + const content = createElement('content-node'); + const button = createElement('button-node'); + + renderer.render(null, [], [], scripts); + renderer.render('Second', [content], [button], scripts); + + assert.deepEqual(scriptsElement.children, scripts); + assert.equal(titleElement.textContent, 'Second'); + assert.deepEqual(contentElement.children, [content]); + assert.deepEqual(buttonsElement.children, [button, closeDialogElement]); + }); + + it('keeps the script history per renderer instance', () => { + const otherScriptsElement = createElement('other-scripts'); + const other = new PromptRenderer( + createElement('close'), + createElement('title'), + createElement('content'), + createElement('buttons'), + otherScriptsElement, + ); + const scripts = [createScript('a')]; + + renderer.render(null, [], [], scripts); + other.render(null, [], [], scripts); + + assert.deepEqual(scriptsElement.children, scripts); + assert.deepEqual(otherScriptsElement.children, scripts); + }); + }); +}); diff --git a/components/ILIAS/UI/tests/Client/Prompt/PromptStateLoader.js b/components/ILIAS/UI/tests/Client/Prompt/PromptStateLoader.js new file mode 100644 index 000000000000..931a3b6e175b --- /dev/null +++ b/components/ILIAS/UI/tests/Client/Prompt/PromptStateLoader.js @@ -0,0 +1,194 @@ +/** + * 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 + */ + +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import * as PROMPT from '../../../resources/js/Prompt/src/constants.js'; +import PromptState from '../../../resources/js/Prompt/src/PromptState.js'; +import PromptStateLoader from '../../../resources/js/Prompt/src/PromptStateLoader.js'; + +/** + * Creates the smallest possible fake DocumentFragment that createPromptState() + * can work with: it only knows the instruction attribute, everything else is + * "not found". The functionality of createPromptState() itself is not tested + * here, the loader just needs valid (or invalid) input for it. + * + * @param {string|null} instruction null creates an invalid fragment + */ +function createFragment(instruction = 'show') { + const instructionSelector = `[${PROMPT.PROMPT_STATE_INSTRUCTION_ATTRIBUTE}]`; + return { + querySelector: (selector) => (instruction !== null && selector === instructionSelector + ? { getAttribute: (name) => (name === PROMPT.PROMPT_STATE_INSTRUCTION_ATTRIBUTE ? instruction : null) } + : null), + querySelectorAll: () => [], + }; +} + +const url = (href) => new URL(href); + +describe('PromptStateLoader', () => { + let asyncRenderer; + let loader; + + beforeEach(() => { + asyncRenderer = { loadContent: mock.fn(async () => createFragment()) }; + loader = new PromptStateLoader(asyncRenderer); + }); + + describe('load()', () => { + it('loads the content via the async renderer and returns it as prompt state', async () => { + const endpoint = url('https://example.com/prompt?cmd=show'); + const request = { method: 'GET' }; + + const state = await loader.load(endpoint, request); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 1); + const [passedEndpoint, passedRequest] = asyncRenderer.loadContent.mock.calls[0].arguments; + assert.equal(passedEndpoint, endpoint); + assert.equal(passedRequest, request); + assert.ok(state instanceof PromptState); + }); + + it('defaults the request to an empty object', async () => { + await loader.load(url('https://example.com/a')); + + assert.deepEqual(asyncRenderer.loadContent.mock.calls[0].arguments[1], {}); + }); + + it('returns the cached state for a repeated request without body', async () => { + const first = await loader.load(url('https://example.com/a')); + const second = await loader.load(url('https://example.com/a')); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 1); + assert.equal(second, first); + }); + + it('uses the url href as cache key, not the URL instance', async () => { + const first = await loader.load(url('https://example.com/a?x=1')); + const second = await loader.load(url('https://example.com/a?x=1')); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 1); + assert.equal(second, first); + }); + + it('caches different urls separately', async () => { + await loader.load(url('https://example.com/a')); + await loader.load(url('https://example.com/b')); + await loader.load(url('https://example.com/a?x=1')); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 3); + }); + + it('bypasses the cache for requests with a body', async () => { + const endpoint = url('https://example.com/a'); + await loader.load(endpoint); + await loader.load(endpoint, { method: 'POST', body: new URLSearchParams({ a: '1' }) }); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 2); + }); + + it('does not use the cache for body requests even when nothing is cached yet', async () => { + const endpoint = url('https://example.com/a'); + const body = new URLSearchParams({ a: '1' }); + + await loader.load(endpoint, { method: 'POST', body }); + await loader.load(endpoint, { method: 'POST', body }); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 2); + }); + + it('replaces the cached state with the result of a body request', async () => { + const endpoint = url('https://example.com/a'); + const cached = await loader.load(endpoint); + const posted = await loader.load(endpoint, { method: 'POST', body: new URLSearchParams() }); + const afterwards = await loader.load(endpoint); + + assert.notEqual(posted, cached); + assert.equal(afterwards, posted); + assert.equal(asyncRenderer.loadContent.mock.callCount(), 2); + }); + + it('treats requests without body as cacheable regardless of other options', async () => { + const endpoint = url('https://example.com/a'); + const first = await loader.load(endpoint, { method: 'GET' }); + const second = await loader.load(endpoint, { method: 'GET', headers: { 'X-Test': '1' } }); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 1); + assert.equal(second, first); + }); + + it('propagates errors from the async renderer', async () => { + const error = new Error('network down'); + asyncRenderer.loadContent = mock.fn(async () => { + throw error; + }); + + await assert.rejects( + loader.load(url('https://example.com/a')), + (thrown) => thrown === error, + ); + }); + + it('does not cache failed loads', async () => { + const endpoint = url('https://example.com/a'); + asyncRenderer.loadContent = mock.fn(async () => { + throw new Error('fail'); + }); + await assert.rejects(loader.load(endpoint)); + + asyncRenderer.loadContent = mock.fn(async () => createFragment()); + const state = await loader.load(endpoint); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 1); + assert.ok(state instanceof PromptState); + }); + + it('rejects if the loaded content cannot be converted to a prompt state', async () => { + asyncRenderer.loadContent = mock.fn(async () => createFragment(null)); + + await assert.rejects( + loader.load(url('https://example.com/a')), + { message: 'State must provide instruction' }, + ); + }); + + it('does not cache content that could not be converted to a prompt state', async () => { + const endpoint = url('https://example.com/a'); + asyncRenderer.loadContent = mock.fn(async () => createFragment(null)); + await assert.rejects(loader.load(endpoint)); + + asyncRenderer.loadContent = mock.fn(async () => createFragment()); + const state = await loader.load(endpoint); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 1); + assert.ok(state instanceof PromptState); + }); + + it('keeps separate caches per loader instance', async () => { + const otherRenderer = { loadContent: mock.fn(async () => createFragment()) }; + const otherLoader = new PromptStateLoader(otherRenderer); + const endpoint = url('https://example.com/a'); + + await loader.load(endpoint); + await otherLoader.load(endpoint); + + assert.equal(asyncRenderer.loadContent.mock.callCount(), 1); + assert.equal(otherRenderer.loadContent.mock.callCount(), 1); + }); + }); +}); diff --git a/components/ILIAS/UI/tests/Component/Entity/EntityTest.php b/components/ILIAS/UI/tests/Component/Entity/EntityTest.php index a195d16a4102..6308ba5cc6d3 100755 --- a/components/ILIAS/UI/tests/Component/Entity/EntityTest.php +++ b/components/ILIAS/UI/tests/Component/Entity/EntityTest.php @@ -41,15 +41,16 @@ protected function legacy(string $string): Content public function testEntityFactory(): void { - $entity = $this->getEntityFactory()->standard('primary', 'secondary'); + $entity = $this->getEntityFactory()->standard(1, 'primary', 'secondary'); $this->assertInstanceOf("ILIAS\\UI\\Component\\Entity\\Standard", $entity); + $this->assertEquals(1, $entity->getId()); $this->assertEquals('primary', $entity->getPrimaryIdentifier()); $this->assertEquals('secondary', $entity->getSecondaryIdentifier()); } public function testEntityBasicProperties(): void { - $entity = $this->getEntityFactory()->standard('primary', 'secondary'); + $entity = $this->getEntityFactory()->standard(1, 'primary', 'secondary'); $this->assertEquals([$this->legacy('bc')], $entity->withBlockingAvailabilityConditions($this->legacy('bc'))->getBlockingAvailabilityConditions()); $this->assertEquals([$this->legacy('fp')], $entity->withFeaturedProperties($this->legacy('fp'))->getFeaturedProperties()); $this->assertEquals([$this->legacy('md')], $entity->withMainDetails($this->legacy('md'))->getMainDetails()); @@ -58,7 +59,7 @@ public function testEntityBasicProperties(): void $this->assertEquals([$this->legacy('d')], $entity->withDetails($this->legacy('d'))->getDetails()); } - public static function getEntityAllowedIdentiferTypes(): array + public static function getEntityAllowedVisualTypes(): array { $shy_button = new Button\Shy('the label', '#'); $shy_link = new Link\Standard('the label', '#'); @@ -73,12 +74,13 @@ public static function getEntityAllowedIdentiferTypes(): array ]; } - #[\PHPUnit\Framework\Attributes\DataProvider('getEntityAllowedIdentiferTypes')] - public function testEntityIdentifiers($identifier): void + #[\PHPUnit\Framework\Attributes\DataProvider('getEntityAllowedVisualTypes')] + public function testEntityVisualIdentifiers($visual_identifier): void { - $entity = $this->getEntityFactory()->standard($identifier, $identifier); - $this->assertEquals($identifier, $entity->getPrimaryIdentifier()); - $this->assertEquals($identifier, $entity->getSecondaryIdentifier()); + $entity = $this->getEntityFactory()->standard(1, $visual_identifier, $visual_identifier); + $this->assertEquals(1, $entity->getId()); + $this->assertEquals($visual_identifier, $entity->getPrimaryIdentifier()); + $this->assertEquals($visual_identifier, $entity->getSecondaryIdentifier()); } public function testEntityActionProperties(): void @@ -86,7 +88,7 @@ public function testEntityActionProperties(): void $glyph = new Symbol\Glyph\Glyph('laugh', 'some glyph'); $tag = new Button\Tag('tag', '#'); $shy = new Button\Shy('shy', '#'); - $entity = $this->getEntityFactory()->standard('primary', 'secondary') + $entity = $this->getEntityFactory()->standard(1, 'primary', 'secondary') ->withPrioritizedReactions($glyph, $tag) ->withReactions($glyph, $glyph, $glyph) ->withManagingActions($shy); @@ -101,7 +103,7 @@ public function testEntityComponentProperties(): void $glyph = new Symbol\Glyph\Glyph('laugh', 'some glyph'); $tag = new Button\Tag('tag', '#'); $shy = new Button\Shy('shy', '#'); - $entity = $this->getEntityFactory()->standard('primary', 'secondary') + $entity = $this->getEntityFactory()->standard(1, 'primary', 'secondary') ->withPrioritizedReactions($glyph, $tag) ->withReactions($glyph) ->withManagingActions($shy); @@ -130,7 +132,7 @@ public function testEntityWorkflowButtons(): void $video_workflow = $workflow_factory->linear("Video Curation", $steps); - $entity = $this->getEntityFactory()->standard('primary', 'secondary') + $entity = $this->getEntityFactory()->standard(1, 'primary', 'secondary') ->withWorkflow($video_workflow); $rendered_entity = $this->getDefaultRenderer()->render($entity); @@ -164,7 +166,7 @@ public function button(): I\Button\Factory public function listing(): I\Listing\Factory { return new I\Listing\Factory( - new I\Listing\Workflow\Factory, + new I\Listing\Workflow\Factory(), $this->characteristic_value_factory, new I\Listing\Entity\Factory(), ); @@ -176,7 +178,7 @@ public function testEntityRendering(): void $glyph = new Symbol\Glyph\Glyph('laugh', 'some glyph'); $tag = new Button\Tag('tag', '#'); $shy = new Button\Shy('shy', '#'); - $entity = $this->getEntityFactory()->standard('primary', 'secondary') + $entity = $this->getEntityFactory()->standard(1, 'primary', 'secondary') ->withPrioritizedReactions($glyph, $tag) ->withReactions($glyph, $glyph) ->withManagingActions($shy, $shy) diff --git a/components/ILIAS/UI/tests/Component/Input/Container/Filter/FilterInputTest.php b/components/ILIAS/UI/tests/Component/Input/Container/Filter/FilterInputTest.php index 44e462c0b6fa..26b48c51bcec 100644 --- a/components/ILIAS/UI/tests/Component/Input/Container/Filter/FilterInputTest.php +++ b/components/ILIAS/UI/tests/Component/Input/Container/Filter/FilterInputTest.php @@ -261,7 +261,7 @@ public function testRenderDateTimeWithDurationAndFilterContext(): void $duration = $if->duration('label', 'byline'); $datetime = $if->dateTime('label', 'byline'); // byline will not be rendered in this context $filter = $f->standard("#", "#", "#", "#", "#", "#", [], [], false, false); - $dr = $this->getDefaultRenderer(null, [], [$filter, $duration, $datetime]); + $dr = $this->getDefaultRenderer(null, [], [$filter, $duration]); $html = $this->brutallyTrimHTML($dr->render($datetime)); $expected = $this->brutallyTrimHTML(' diff --git a/components/ILIAS/UI/tests/Component/Input/Field/CommonFieldRendering.php b/components/ILIAS/UI/tests/Component/Input/Field/CommonFieldRendering.php index ac2e5f0e0161..a19c1edc5401 100644 --- a/components/ILIAS/UI/tests/Component/Input/Field/CommonFieldRendering.php +++ b/components/ILIAS/UI/tests/Component/Input/Field/CommonFieldRendering.php @@ -101,6 +101,9 @@ public function getOnLoadCodeAsync(): string { return ''; } + public function clearOnLoadCodeAsync(): void + { + } }; diff --git a/components/ILIAS/UI/tests/Component/Input/Field/HiddenListInputTest.php b/components/ILIAS/UI/tests/Component/Input/Field/HiddenListInputTest.php new file mode 100755 index 000000000000..940f1fb5be60 --- /dev/null +++ b/components/ILIAS/UI/tests/Component/Input/Field/HiddenListInputTest.php @@ -0,0 +1,103 @@ +hidden_stub_html = sha1(I\Input\Field\Hidden::class); + $this->hidden_stub = $this->createStub(I\Input\Field\Hidden::class); + $this->hidden_stub->method('getCanonicalName')->willReturn($this->hidden_stub_html); + + parent::setUp(); + } + + public function testRenderWithValue(): void + { + $this->hidden_stub->method('withValue')->willReturnSelf(); + + $value = ['a', 'b', 'c']; + + $hidden_list = $this->getUIFactory()->input()->field()->hiddenList(); + $hidden_list = $hidden_list->withValue($value); + + $value_html = str_repeat($this->hidden_stub_html, count($value)); + + $expected = << + $value_html + +HTML; + + $actual = $this->getDefaultRenderer(null, [$this->hidden_stub])->render($hidden_list); + + $this->assertHTMLEquals($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } + + public function testRenderWithoutValue(): void + { + $hidden_list = $this->getUIFactory()->input()->field()->hiddenList(); + + $expected = ''; + + $actual = $this->getDefaultRenderer(null, [$this->hidden_stub])->render($hidden_list); + + $this->assertSame($expected, $actual); + } + + public function getUIFactory(): \NoUIFactory + { + $partial_field_factory_mock = $this + ->getMockBuilder(I\Input\Field\Factory::class) + ->onlyMethods(['hidden']) + ->setConstructorArgs([ + $this->createStub(I\Input\Field\Node\Factory::class), + $this->getUploadLimitResolver(), + $this->createStub(I\SignalGeneratorInterface::class), + $this->getDataFactory(), + $this->getRefinery(), + $this->getLanguage(), + ])->getMock(); + + $partial_field_factory_mock->method('hidden')->willReturn($this->hidden_stub); + + $input_factory_stub = $this->createStub(I\Input\Factory::class); + $input_factory_stub->method('field')->willReturn($partial_field_factory_mock); + + return new class($input_factory_stub) extends \NoUIFactory { + public function __construct(protected I\Input\Factory $input_factory) + { + } + public function input(): I\Input\Factory + { + return $this->input_factory; + } + }; + } +} diff --git a/components/ILIAS/UI/tests/Component/Item/ItemNotificationTest.php b/components/ILIAS/UI/tests/Component/Item/ItemNotificationTest.php index 15f5acb0024f..35bf884590f3 100755 --- a/components/ILIAS/UI/tests/Component/Item/ItemNotificationTest.php +++ b/components/ILIAS/UI/tests/Component/Item/ItemNotificationTest.php @@ -211,6 +211,11 @@ public function addOnLoadCode(string $code): void public function getOnLoadCodeAsync(): string { } + + public function clearOnLoadCodeAsync(): void + { + $this->on_load_code = []; + } }); $props = array("prop1" => "val1", "prop2" => "val2"); diff --git a/components/ILIAS/UI/tests/Component/Listing/Entity/EntityListingTest.php b/components/ILIAS/UI/tests/Component/Listing/Entity/EntityListingTest.php index 479f902e0d17..e8894746fff4 100755 --- a/components/ILIAS/UI/tests/Component/Listing/Entity/EntityListingTest.php +++ b/components/ILIAS/UI/tests/Component/Listing/Entity/EntityListingTest.php @@ -18,25 +18,42 @@ declare(strict_types=1); -use ILIAS\UI\Implementation\Component\Listing; -use ILIAS\UI\Implementation\Component\Entity; -use ILIAS\UI\Component as I; -use ILIAS\UI\Factory as UIFactory; +use ILIAS\Data\Order; use ILIAS\Data\Range; +use ILIAS\UI\Component as I; +use ILIAS\UI\Implementation\Component\Entity; +use ILIAS\UI\Implementation\Component\Listing; class EntityListingTest extends ILIAS_UI_TestBase { - public function getEntityMapping(): I\Listing\Entity\RecordToEntity + public function getEntityRetrieval(): I\Entity\EntityRetrieval { - return new class () implements I\Listing\Entity\RecordToEntity { - public function map( - UIFactory $ui_factory, - mixed $record - ): Entity\Entity { - return $ui_factory->entity()->standard('primary', 'secondary'); + return new class () implements I\Entity\EntityRetrieval { + public function getEntities( + ILIAS\UI\Factory $ui_factory, + Range $range, + Order $order, + mixed $additional_viewcontrol_data, + mixed $filter_data, + mixed $additional_parameters, + ): Generator { + for ($i = 1; $i <= 3; $i++) { + yield $ui_factory->entity()->standard($i, 'primary ' . $i, 'secondary ' . $i); + } + } + + public function getEntitiesByIds( + ILIAS\UI\Factory $ui_factory, + Order $order, + array $entity_ids, + ): Generator { + foreach ($entity_ids as $entity_id) { + yield $ui_factory->entity()->standard($entity_id, 'primary ' . $entity_id, 'secondary ' . $entity_id); + } } }; } + public function getUIFactory(): NoUIFactory { return new class () extends NoUIFactory { @@ -48,6 +65,7 @@ public function listing(): Listing\Factory new Listing\Entity\Factory(), ); } + public function entity(): Entity\Factory { return new Entity\Factory(); @@ -58,35 +76,19 @@ public function entity(): Entity\Factory public function testEntityListingFactory(): void { $this->assertInstanceOf( - I\Listing\Entity\EntityListing::class, - $this->getUIFactory()->listing()->entity()->standard($this->getEntityMapping()) + I\Listing\Entity\Entity::class, + $this->getUIFactory()->listing()->entity()->standard($this->getEntityRetrieval()) ); } public function testEntityListingYieldingEntities(): void { - $data = new class () implements I\Listing\Entity\DataRetrieval { - protected $data = [1,2,3]; - - public function getEntities( - I\Listing\Entity\Mapping $mapping, - ?Range $range, - ?array $additional_parameters - ): \Generator { - foreach ($this->data as $entry) { - yield $mapping->map($entry); - } - } - }; - $listing = $this->getUIFactory()->listing()->entity() - ->standard($this->getEntityMapping()) - ->withData($data); + ->standard($this->getEntityRetrieval()); $entities = iterator_to_array($listing->getEntities($this->getUIFactory())); $this->assertCount(3, $entities); - $this->assertInstanceOf(I\Entity\Entity::class, array_pop($entities)); } } diff --git a/components/ILIAS/UI/tests/Component/Listing/Entity/GridEntityListingTest.php b/components/ILIAS/UI/tests/Component/Listing/Entity/GridEntityListingTest.php index 35b5a85a26fb..ff645882b15d 100644 --- a/components/ILIAS/UI/tests/Component/Listing/Entity/GridEntityListingTest.php +++ b/components/ILIAS/UI/tests/Component/Listing/Entity/GridEntityListingTest.php @@ -18,25 +18,43 @@ declare(strict_types=1); +use ILIAS\Data\Order; +use ILIAS\Data\Range; +use ILIAS\UI\Component as C; use ILIAS\UI\Implementation\Component\Entity; use ILIAS\UI\Implementation\Component as I; -use ILIAS\UI\Component as C; use ILIAS\UI\Factory as UIFactory; -use ILIAS\Data\Range; class GridEntityListingTest extends ILIAS_UI_TestBase { - public function getEntityMapping(): C\Listing\Entity\RecordToEntity + public function getEntityRetrieval(): C\Entity\EntityRetrieval { - return new class () implements C\Listing\Entity\RecordToEntity { - public function map( + return new class () implements C\Entity\EntityRetrieval { + public function getEntities( + UIFactory $ui_factory, + Range $range, + Order $order, + mixed $additional_viewcontrol_data, + mixed $filter_data, + mixed $additional_parameters, + ): Generator { + for ($i = 1; $i <= 3; $i++) { + yield $ui_factory->entity()->standard($i, 'primary', 'secondary'); + } + } + + public function getEntitiesByIds( UIFactory $ui_factory, - mixed $record - ): Entity\Entity { - return $ui_factory->entity()->standard('primary', 'secondary'); + Order $order, + array $entity_ids, + ): Generator { + foreach ($entity_ids as $entity_id) { + yield $ui_factory->entity()->standard($entity_id, 'primary', 'secondary'); + } } }; } + public function getUIFactory(): NoUIFactory { return new class ( @@ -56,7 +74,7 @@ public function listing(): I\Listing\Factory new I\Listing\Entity\Factory(), ); } - public function entity(): I\Entity\Factory + public function entity(): Entity\Factory { return new Entity\Factory(); } @@ -66,34 +84,19 @@ public function entity(): I\Entity\Factory public function testGridEntityListingFactory(): void { $this->assertInstanceOf( - C\Listing\Entity\EntityListing::class, - $this->getUIFactory()->listing()->entity()->grid($this->getEntityMapping()) + C\Listing\Entity\Entity::class, + $this->getUIFactory()->listing()->entity()->grid($this->getEntityRetrieval()) ); } public function testGridEntityListingRendering(): void { - $data = new class () implements C\Listing\Entity\DataRetrieval { - protected $data = [1,2,3]; - - public function getEntities( - C\Listing\Entity\Mapping $mapping, - ?Range $range, - ?array $additional_parameters - ): \Generator { - foreach ($this->data as $entry) { - yield $mapping->map($entry); - } - } - }; - $listing = $this->getUIFactory()->listing()->entity() - ->grid($this->getEntityMapping()) - ->withData($data); + ->grid($this->getEntityRetrieval()); $render = $this->getDefaultRenderer()->render($listing); $expected = << +
      • +HTML; + + $actual = $renderer->render($prompt); + + $this->assertEquals($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } + + public function testRenderWithTitle(): void + { + $prompt = $this->getUIFactory()->prompt()->standard($this->uri_stub); + + $title = sha1('Hello World!'); + $prompt = $prompt->withTitle($title); + + $renderer = $this->getDefaultRenderer(null, [$this->message_box_stub]); + + $expected = <<$title +HTML; + + $actual = $renderer->render($prompt); + + $this->assertStringContainsString($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } +} diff --git a/components/ILIAS/UI/tests/Component/Prompt/State/PromptStateTest.php b/components/ILIAS/UI/tests/Component/Prompt/State/PromptStateTest.php new file mode 100644 index 000000000000..56eab692de64 --- /dev/null +++ b/components/ILIAS/UI/tests/Component/Prompt/State/PromptStateTest.php @@ -0,0 +1,288 @@ +uri_stub_value = sha1(\ILIAS\Data\URI::class); + $this->uri_stub = $this->createStub(\ILIAS\Data\URI::class); + $this->uri_stub->method('__toString')->willReturn($this->uri_stub_value); + + $this->prompt_content_stub_html = sha1(C\Prompt\IsPromptContent::class); + $this->prompt_content_stub = $this->createStub(C\Prompt\IsPromptContent::class); + $this->prompt_content_stub->method('getCanonicalName')->willReturn($this->prompt_content_stub_html); + + $this->button_stub_html = sha1(I\Button\Standard::class); + $this->button_stub = $this->createStub(I\Button\Standard::class); + $this->button_stub->method('getCanonicalName')->willReturn($this->button_stub_html); + + $this->form_stub_html = sha1(I\Input\Container\Form\Standard::class); + $this->form_stub = $this->createStub(I\Input\Container\Form\Standard::class); + $this->form_stub->method('getCanonicalName')->willReturn($this->form_stub_html); + + $this->message_box_stub_html = sha1(I\MessageBox\MessageBox::class); + $this->message_box_stub = $this->createStub(I\MessageBox\MessageBox::class); + $this->message_box_stub->method('getCanonicalName')->willReturn($this->message_box_stub_html); + + parent::setUp(); + } + + public function getUIFactory(): \NoUIFactory + { + $real_state_factory = new I\Prompt\State\Factory(); + + $prompt_factory_stub = $this->createStub(I\Prompt\Factory::class); + $prompt_factory_stub->method('state')->willReturn($real_state_factory); + + $button_factory_stub = $this->createStub(I\Button\Factory::class); + $button_factory_stub->method('standard')->willReturn($this->button_stub); + + return new class($prompt_factory_stub, $button_factory_stub) extends \NoUIFactory { + public function __construct( + protected I\Prompt\Factory $prompt_factory, + protected I\Button\Factory $button_factory, + ) { + } + public function button(): I\Button\Factory + { + return $this->button_factory; + } + public function prompt(): I\Prompt\Factory + { + return $this->prompt_factory; + } + }; + } + + public function testCloseComposition(): void + { + $state = $this->getUIFactory()->prompt()->state()->close(); + + $this->assertSame(I\Prompt\State\Instruction::CLOSE, $state->getInstruction()); + $this->assertNull($state->getTitle()); + $this->assertNull($state->getRedirect()); + $this->assertNull($state->getPrimaryContent()); + $this->assertEmpty($state->getSecondaryContent()); + } + + public function testRedirectComposition(): void + { + $state = $this->getUIFactory()->prompt()->state()->redirect($this->uri_stub); + + $this->assertSame(I\Prompt\State\Instruction::REDIRECT, $state->getInstruction()); + $this->assertNull($state->getTitle()); + $this->assertSame($state->getRedirect(), $this->uri_stub); + $this->assertNull($state->getPrimaryContent()); + $this->assertEmpty($state->getSecondaryContent()); + } + + public function testShowCompositionWithPrimaryContent(): void + { + $state = $this->getUIFactory()->prompt()->state()->show($this->prompt_content_stub); + + $this->assertSame(I\Prompt\State\Instruction::SHOW, $state->getInstruction()); + $this->assertNull($state->getTitle()); + $this->assertNull($state->getRedirect()); + $this->assertSame($state->getPrimaryContent(), $this->prompt_content_stub); + $this->assertEmpty($state->getSecondaryContent()); + } + + #[Depends('testShowCompositionWithPrimaryContent')] + public function testShowCompositionWithSecondaryContent(): void + { + $primary_content = clone $this->prompt_content_stub; + $secondary_content = clone $this->prompt_content_stub; + + $state = $this->getUIFactory()->prompt()->state()->show($primary_content, $secondary_content); + + $this->assertSame($state->getPrimaryContent(), $primary_content); + $this->assertSame($state->getSecondaryContent(), [$secondary_content]); + } + + #[Depends('testCloseComposition')] + public function testRenderClose(): void + { + $state = $this->getUIFactory()->prompt()->state()->close(); + $renderer = $this->getDefaultRenderer(); + + $expected_instruction = I\Prompt\State\Instruction::CLOSE->value; + $expected = << +HTML; + + $actual = $renderer->render($state); + + $this->assertStringContainsString($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } + + #[Depends('testRedirectComposition')] + public function testRenderRedirect(): void + { + $state = $this->getUIFactory()->prompt()->state()->redirect($this->uri_stub); + $renderer = $this->getDefaultRenderer(); + + $expected_instruction = I\Prompt\State\Instruction::REDIRECT->value; + $expected = << +HTML; + + $actual = $renderer->render($state); + + $this->assertStringContainsString($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } + + #[Depends('testShowCompositionWithPrimaryContent')] + public function testRenderShowWithFormAsPrimaryContent(): void + { + $state = $this->getUIFactory()->prompt()->state()->show($this->form_stub); + $renderer = $this->getDefaultRenderer(null, [$this->button_stub, $this->form_stub]); + + $expected = << + $this->form_stub_html +
        +
        $this->button_stub_html
        +HTML; + + $actual = $renderer->render($state); + + $this->assertStringContainsString($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } + + #[Depends('testRenderShowWithFormAsPrimaryContent')] + public function testRenderShowWithFormAndAdditionalSubmitButtonsAsPrimaryContent(): void + { + $state = $this->getUIFactory()->prompt()->state()->show($this->form_stub); + $renderer = $this->getDefaultRenderer(null, [$this->button_stub, $this->form_stub]); + + $this->form_stub->method('getAdditionalFormActions')->willReturn(['action' => 'label']); + + $expected_button_html = str_repeat($this->button_stub_html, 2); + $expected = << + $this->form_stub_html + +
        $expected_button_html
        +HTML; + + $actual = $renderer->render($state); + + $this->assertStringContainsString($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } + + #[Depends('testShowCompositionWithPrimaryContent')] + public function testRenderShowWithMessageBoxAsPrimaryContent(): void + { + $state = $this->getUIFactory()->prompt()->state()->show($this->message_box_stub); + $renderer = $this->getDefaultRenderer(null, [$this->message_box_stub]); + + $expected_instruction = I\Prompt\State\Instruction::SHOW->value; + $expected = << + $this->message_box_stub_html + +
        +HTML; + + $actual = $renderer->render($state); + + $this->assertStringContainsString($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } + + #[Depends('testRenderShowWithMessageBoxAsPrimaryContent')] + public function testRenderShowWithMessageBoxAndButtonsAsPrimaryContent(): void + { + $state = $this->getUIFactory()->prompt()->state()->show($this->message_box_stub); + $renderer = $this->getDefaultRenderer(null, [$this->button_stub, $this->message_box_stub]); + + $this->message_box_stub->method('getButtons')->willReturn([$this->button_stub]); + + $expected_instruction = I\Prompt\State\Instruction::SHOW->value; + $expected = << + $this->message_box_stub_html + +
        $this->button_stub_html
        +HTML; + + $actual = $renderer->render($state); + + $this->assertStringContainsString($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } + + #[Depends('testShowCompositionWithSecondaryContent')] + public function testRenderShowWithMessageBoxAsSecondaryContent(): void + { + $state = $this->getUIFactory()->prompt()->state()->show($this->prompt_content_stub, $this->message_box_stub); + $renderer = $this->getDefaultRenderer(null, [$this->prompt_content_stub, $this->message_box_stub]); + + $expected = << + $this->message_box_stub_html + $this->prompt_content_stub_html + +HTML; + + $actual = $renderer->render($state); + + $this->assertStringContainsString($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } + + #[Depends('testShowCompositionWithSecondaryContent')] + public function testRenderShowWithArbitrarySecondaryContent(): void + { + $state = $this->getUIFactory()->prompt()->state()->show($this->form_stub, $this->prompt_content_stub); + $renderer = $this->getDefaultRenderer(null, [ + $this->button_stub, + $this->form_stub, + $this->prompt_content_stub, + ]); + + $expected = << + $this->form_stub_html + $this->prompt_content_stub_html + +HTML; + + $actual = $renderer->render($state); + + $this->assertStringContainsString($this->brutallyTrimHTML($expected), $this->brutallyTrimHTML($actual)); + } +} diff --git a/components/ILIAS/UI/tests/Component/Prompt/State/StateTest.php b/components/ILIAS/UI/tests/Component/Prompt/State/StateTest.php deleted file mode 100644 index f1aa444462ca..000000000000 --- a/components/ILIAS/UI/tests/Component/Prompt/State/StateTest.php +++ /dev/null @@ -1,147 +0,0 @@ -close_button = $this->createStub(I\Button\Standard::class); - $this->close_button_html = sha1(I\Button\Standard::class); - $this->close_button->method('getCanonicalName')->willReturn($this->close_button_html); - $this->close_button->method('withOnLoadCode')->willReturn($this->close_button); - - $this->button_factory = $this->createStub(I\Button\Factory::class); - $this->button_factory->method('standard')->willReturn($this->close_button); - - parent::setUp(); - } - - public function getUIFactory(): NoUIFactory - { - return new class ($this->button_factory) extends NoUIFactory { - public function __construct( - protected I\Button\Factory $button_factory - ) { - } - - public function button(): I\Button\Factory - { - return $this->button_factory; - } - }; - } - - public function testWithAdditionalSecondaryContent(): void - { - $content_1 = $this->createStub(C\Prompt\IsPromptContent::class); - $content_2 = $this->createStub(C\Prompt\IsPromptContent::class); - - $state = new I\Prompt\State\State(null, $content_1); - $state = $state->withAdditionalSecondaryContent($content_2); - - $this->assertSame($state->getSecondaryContent(), [$content_1, $content_2]); - } - - public function testRenderGenericSecondaryContentBelowPrimaryContent(): void - { - $primary = $this->createStub(C\Prompt\IsPromptContent::class); - $primary_html = sha1(C\Prompt\IsPromptContent::class); - $primary->method('getCanonicalName')->willReturn($primary_html); - $secondary = $this->createStub(C\Prompt\IsPromptContent::class); - $secondary_html = sha1(C\MessageBox\MessageBox::class); - $secondary->method('getCanonicalName')->willReturn($secondary_html); - - $r = $this->getDefaultRenderer(null, [$primary, $secondary, $this->close_button]); - - $state = new I\Prompt\State\State($primary, $secondary); - $actual_html = $r->render($state); - - $expected_html = << -
        -
        - $primary_html - $secondary_html -
        -
        - $this->close_button_html -
        -
        - show -
        -
        - -EOT; - - $this->assertEquals( - $this->brutallyTrimHTML($expected_html), - $this->brutallyTrimHTML($actual_html), - ); - } - - public function testRenderSecondaryMessageBoxAbovePrimaryContent(): void - { - $primary = $this->createStub(C\Prompt\IsPromptContent::class); - $primary_html = sha1(C\Prompt\IsPromptContent::class); - $primary->method('getCanonicalName')->willReturn($primary_html); - $message_box = $this->createStub(C\MessageBox\MessageBox::class); - $message_box_html = sha1(C\MessageBox\MessageBox::class); - $message_box->method('getCanonicalName')->willReturn($message_box_html); - - $r = $this->getDefaultRenderer(null, [$primary, $message_box, $this->close_button]); - - $state = new I\Prompt\State\State($primary, $message_box); - $actual_html = $r->render($state); - - $expected_html = << -
        -
        - $message_box_html - $primary_html -
        -
        - $this->close_button_html -
        -
        - show -
        -
        - -EOT; - - $this->assertEquals( - $this->brutallyTrimHTML($expected_html), - $this->brutallyTrimHTML($actual_html), - ); - } -} diff --git a/components/ILIAS/UI/tests/InitUIFramework.php b/components/ILIAS/UI/tests/InitUIFramework.php index b5425805726e..06ced642de7a 100755 --- a/components/ILIAS/UI/tests/InitUIFramework.php +++ b/components/ILIAS/UI/tests/InitUIFramework.php @@ -316,12 +316,16 @@ public function getRefreshIntervalInMs(): int $c["lng"], ); }; + $c["ui.context_matcher"] = function ($c) { + return new ILIAS\UI\Implementation\Render\ContextMatcher(); + }; $c["ui.component_renderer_loader"] = function ($c) { return new ILIAS\UI\Implementation\Render\LoaderCachingWrapper( new ILIAS\UI\Implementation\Render\LoaderResourceRegistryWrapper( $c["ui.resource_registry"], new ILIAS\UI\Implementation\Render\FSLoader( new ILIAS\UI\Implementation\Render\DefaultRendererFactory( + $c["ui.context_matcher"], $c["ui.factory"], $c["ui.template_factory"], $c["lng"], @@ -332,6 +336,7 @@ public function getRefreshIntervalInMs(): int $c["ui.upload_limit_resolver"] ), new ILIAS\UI\Implementation\Component\Button\ButtonRendererFactory( + $c["ui.context_matcher"], $c["ui.factory"], $c["ui.template_factory"], $c["lng"], @@ -342,6 +347,7 @@ public function getRefreshIntervalInMs(): int $c["ui.upload_limit_resolver"] ), new ILIAS\UI\Implementation\Component\Input\Field\FieldRendererFactory( + $c["ui.context_matcher"], $c["ui.factory"], $c["ui.template_factory"], $c["lng"], @@ -352,6 +358,7 @@ public function getRefreshIntervalInMs(): int $c["ui.upload_limit_resolver"] ), new ILIAS\UI\Implementation\Component\MessageBox\MessageBoxRendererFactory( + $c["ui.context_matcher"], $c["ui.factory"], $c["ui.template_factory"], $c["lng"], @@ -362,6 +369,7 @@ public function getRefreshIntervalInMs(): int $c["ui.upload_limit_resolver"] ), new ILIAS\UI\Implementation\Component\Input\Container\Form\FormRendererFactory( + $c["ui.context_matcher"], $c["ui.factory"], $c["ui.template_factory"], $c["lng"], @@ -372,6 +380,7 @@ public function getRefreshIntervalInMs(): int $c["ui.upload_limit_resolver"] ), new ILIAS\UI\Implementation\Component\Menu\MenuRendererFactory( + $c["ui.context_matcher"], $c["ui.factory"], $c["ui.template_factory"], $c["lng"], @@ -381,7 +390,19 @@ public function getRefreshIntervalInMs(): int $c["help.text_retriever"], $c["ui.upload_limit_resolver"] ), + new ILIAS\UI\Implementation\Component\Entity\EntityRendererFactory( + $c["ui.context_matcher"], + $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"], + ), new ILIAS\UI\Implementation\Component\Listing\ListingRendererFactory( + $c["ui.context_matcher"], $c["ui.factory"], $c["ui.template_factory"], $c["lng"], @@ -430,7 +451,13 @@ public function getRefreshIntervalInMs(): int }; $c["ui.factory.prompt"] = function ($c) { - return new ILIAS\UI\Implementation\Component\Prompt\Factory($c["ui.signal_generator"]); + return new ILIAS\UI\Implementation\Component\Prompt\Factory( + $c["ui.signal_generator"], + $c["ui.factory.prompt.state"], + ); + }; + $c["ui.factory.prompt.state"] = function ($c) { + return new ILIAS\UI\Implementation\Component\Prompt\State\Factory(); }; $c["ui.factory.navigation"] = function ($c) { diff --git a/components/ILIAS/UI/tests/Renderer/ContextMatcherTest.php b/components/ILIAS/UI/tests/Renderer/ContextMatcherTest.php new file mode 100644 index 000000000000..ff295add633e --- /dev/null +++ b/components/ILIAS/UI/tests/Renderer/ContextMatcherTest.php @@ -0,0 +1,148 @@ + + */ +class ContextMatcherTest extends TestCase +{ + private ContextMatcher $matcher; + + protected function setUp(): void + { + $this->matcher = new ContextMatcher(); + } + + public static function directAnyProvider(): array + { + return [ + 'direct ancestor matches' => [['A', 'B', 'C', 'D'], ['C'], true], + 'direct ancestor among several' => [['A', 'B', 'C', 'D'], ['X', 'C'], true], + 'minimal context' => [['C', 'D'], ['C'], true], + 'indirect ancestor only' => [['A', 'B', 'C', 'D'], ['A'], false], + 'other indirect ancestor only' => [['A', 'B', 'C', 'D'], ['B'], false], + 'current component only' => [['A', 'B', 'C', 'D'], ['D'], false], + 'indirect and direct' => [['A', 'B', 'C', 'D'], ['A', 'C'], true], + 'no match' => [['A', 'B', 'C', 'D'], ['X', 'Y'], false], + 'empty search' => [['A', 'B', 'C', 'D'], [], false], + 'context too small' => [['D'], ['D'], false], + 'empty context' => [[], ['A'], false], + ]; + } + + #[DataProvider('directAnyProvider')] + public function testIsDirectDescendantOfAny(array $context, array $search, bool $expected): void + { + $this->assertSame($expected, $this->matcher->isDirectDescendantOfAny($context, $search)); + } + + public static function directExactlyProvider(): array + { + return [ + 'single direct ancestor' => [['A', 'B', 'C', 'D'], ['C'], true], + 'two ancestors in order' => [['A', 'B', 'C', 'D'], ['B', 'C'], true], + 'all ancestors in order' => [['A', 'B', 'C', 'D'], ['A', 'B', 'C'], true], + 'minimal context' => [['C', 'D'], ['C'], true], + 'not contiguous' => [['A', 'B', 'C', 'D'], ['A', 'C'], false], + 'wrong order' => [['A', 'B', 'C', 'D'], ['C', 'B'], false], + 'indirect ancestor only' => [['A', 'B', 'C', 'D'], ['B'], false], + 'current component only' => [['A', 'B', 'C', 'D'], ['D'], false], + 'includes current component' => [['A', 'B', 'C', 'D'], ['C', 'D'], false], + 'search spans whole context' => [['A', 'B', 'C', 'D'], ['A', 'B', 'C', 'D'], false], + 'search longer than context' => [['C', 'D'], ['A', 'B', 'C'], false], + 'empty search' => [['A', 'B', 'C', 'D'], [], false], + 'context too small' => [['D'], ['C'], false], + 'empty context' => [[], ['C'], false], + 'duplicates, sequence at end' => [['A', 'B', 'A', 'B', 'D'], ['A', 'B'], true], + 'duplicates, wrong order at end' => [['A', 'B', 'A', 'B', 'D'], ['B', 'A'], false], + 'sequence only further up' => [['A', 'B', 'X', 'D'], ['A', 'B'], false], + ]; + } + + #[DataProvider('directExactlyProvider')] + public function testIsDirectDescendantOf(array $context, array $search, bool $expected): void + { + $this->assertSame($expected, $this->matcher->isDirectDescendantOf($context, $search)); + } + + public static function indirectAnyProvider(): array + { + return [ + 'first indirect ancestor' => [['A', 'B', 'C', 'D'], ['A'], true], + 'second indirect ancestor' => [['A', 'B', 'C', 'D'], ['B'], true], + 'indirect among several' => [['A', 'B', 'C', 'D'], ['X', 'B'], true], + 'indirect and direct' => [['A', 'B', 'C', 'D'], ['A', 'C'], true], + 'direct ancestor only' => [['A', 'B', 'C', 'D'], ['C'], false], + 'current component only' => [['A', 'B', 'C', 'D'], ['D'], false], + 'no match' => [['A', 'B', 'C', 'D'], ['X', 'Y'], false], + 'empty search' => [['A', 'B', 'C', 'D'], [], false], + 'minimal context' => [['A', 'B', 'C'], ['A'], true], + 'minimal context, direct ancestor' => [['A', 'B', 'C'], ['B'], false], + 'context too small' => [['B', 'C'], ['B'], false], + 'single entry' => [['C'], ['C'], false], + 'empty context' => [[], ['A'], false], + ]; + } + + #[DataProvider('indirectAnyProvider')] + public function testIsIndirectDescendantOfAny(array $context, array $search, bool $expected): void + { + $this->assertSame($expected, $this->matcher->isIndirectDescendantOfAny($context, $search)); + } + + public static function indirectExactlyProvider(): array + { + return [ + 'first indirect ancestor' => [['A', 'B', 'C', 'D'], ['A'], true], + 'second indirect ancestor' => [['A', 'B', 'C', 'D'], ['B'], true], + 'both indirect ancestors in order' => [['A', 'B', 'C', 'D'], ['A', 'B'], true], + 'both indirect ancestors, wrong order' => [['A', 'B', 'C', 'D'], ['B', 'A'], false], + 'reaches direct ancestor' => [['A', 'B', 'C', 'D'], ['B', 'C'], false], + 'not contiguous' => [['A', 'B', 'C', 'D'], ['A', 'C'], false], + 'direct ancestor only' => [['A', 'B', 'C', 'D'], ['C'], false], + 'current component only' => [['A', 'B', 'C', 'D'], ['D'], false], + 'search longer than indirect ancestors' => [['A', 'B', 'C', 'D'], ['A', 'B', 'C'], false], + 'empty search' => [['A', 'B', 'C', 'D'], [], false], + 'minimal context' => [['A', 'B', 'C'], ['A'], true], + 'minimal context, direct ancestor' => [['A', 'B', 'C'], ['B'], false], + 'context too small' => [['B', 'C'], ['B'], false], + 'single entry' => [['C'], ['C'], false], + 'empty context' => [[], ['A'], false], + 'sequence at start of window' => [['A', 'B', 'X', 'Y', 'D'], ['A', 'B'], true], + 'sequence in the middle' => [['X', 'A', 'B', 'Y', 'Z', 'D'], ['A', 'B'], true], + 'sequence at end of window' => [['X', 'A', 'B', 'C', 'D'], ['A', 'B'], true], + 'sequence straddles window end' => [['X', 'A', 'B', 'C', 'D'], ['B', 'C'], false], + 'duplicates do not count twice' => [['A', 'A', 'X', 'Y', 'Z'], ['A', 'B'], false], + 'duplicate sequence' => [['A', 'A', 'X', 'Y', 'Z'], ['A', 'A'], true], + 'sequence only at direct ancestor' => [['A', 'B', 'C', 'B', 'D'], ['C', 'B'], false], + 'sequence also in indirect ancestors' => [['A', 'B', 'C', 'B', 'D'], ['A', 'B'], true], + 'partial match then full match' => [['A', 'A', 'B', 'X', 'D'], ['A', 'B'], true], + ]; + } + + #[DataProvider('indirectExactlyProvider')] + public function testIsIndirectDescendantOf(array $context, array $search, bool $expected): void + { + $this->assertSame($expected, $this->matcher->isIndirectDescendantOf($context, $search)); + } +} diff --git a/components/ILIAS/UI/tests/Renderer/DefaultRendererTest.php b/components/ILIAS/UI/tests/Renderer/DefaultRendererTest.php index c7bbb922f83d..ea4db70f9bef 100755 --- a/components/ILIAS/UI/tests/Renderer/DefaultRendererTest.php +++ b/components/ILIAS/UI/tests/Renderer/DefaultRendererTest.php @@ -198,6 +198,39 @@ public function testRenderAsyncWithJsTwice(): void $this->assertEquals('foo', $html); } + public function testClearOnLoadCodeBeforeSyncRendering(): void + { + $js_binding = $this->createMock(JavaScriptBinding::class); + $js_binding->expects($this->never())->method('clearOnLoadCodeAsync'); + + $sync_component_1 = new JSTestComponent("foo"); + $sync_component_2 = new JSTestComponent("bar"); + + $renderer = $this->getDefaultRenderer($js_binding); + + $sync_html_1 = $renderer->render($sync_component_1); + $sync_html_2 = $renderer->render($sync_component_2); + + $this->assertStringNotContainsString("script", $sync_html_1); + $this->assertStringNotContainsString("script", $sync_html_2); + } + + public function testClearOnLoadCodeBeforeAsyncRendering(): void + { + $js_binding = $this->createMock(JavaScriptBinding::class); + $js_binding->expects($this->once())->method('clearOnLoadCodeAsync'); + + $sync_component = new JSTestComponent("foo"); + $async_component = new JSTestComponent("bar"); + + $renderer = $this->getDefaultRenderer($js_binding); + + $sync_html = $renderer->render($sync_component); + $async_html = $renderer->renderAsync($async_component); + + $this->assertStringNotContainsString("foo", $async_html); + } + public function testRenderAsyncArray(): void { $c1 = new TestComponent("foo"); diff --git a/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php b/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php index 634360242a54..979c30e606ec 100755 --- a/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php +++ b/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php @@ -28,10 +28,12 @@ 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\Entity\Entity; use ILIAS\UI\Component\Component; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; class FSLoaderTest extends TestCase { @@ -41,6 +43,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 & Stub $entity_renderer_factory; protected RendererFactory & MockObject $list_renderer_factory; protected FSLoader $fs_loader; @@ -53,6 +56,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->entity_renderer_factory = $this->createStub(RendererFactory::class); $this->list_renderer_factory = $this->createMock(RendererFactory::class); $this->fs_loader = new FSLoader( @@ -62,6 +66,7 @@ protected function setUp(): void $this->message_box_renderer_factory, $this->form_renderer_factory, $this->menu_renderer_factory, + $this->entity_renderer_factory, $this->list_renderer_factory, ); @@ -142,6 +147,13 @@ public function testGetRendererFactoryForButton(): void $this->assertSame($factory, $this->button_renderer_factory); } + public function testGetRendererFactoryForEntity(): void + { + $component_stub = $this->createStub(Entity::class); + $factory = $this->fs_loader->getRendererFactoryFor($component_stub); + $this->assertSame($factory, $this->entity_renderer_factory); + } + public function testGetRendererFactoryForOther(): void { $component_mock = $this->createMock(Component::class); diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index c22c2c7cd160..f8826badf0ca 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -17621,6 +17621,7 @@ ui#:#order_option_numerical_ascending#:#0 bis 9 ui#:#order_option_numerical_descending#:#9 bis 0 ui#:#presentation_table_collapse#:#Alle minimieren ui#:#presentation_table_expand#:#Alle zeigen +ui#:#prompt_request_error#:#Beim Laden vom Inhalt dieses Dialoges ist etwas schief gelaufen. Bitte versuchen Sie es später erneut oder kontaktieren Sie Ihren Support. ui#:#rating_average#:#Andere bewerteten mit %s von 5 ui#:#reset_stars#:#neutral ui#:#select_node#:#Knoten %s zur Auswahl hinzufügen diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index d73220fcc889..78ffdb56b9d4 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -17562,6 +17562,7 @@ ui#:#order_option_numerical_ascending#:#0 to 9 ui#:#order_option_numerical_descending#:#9 to 0 ui#:#presentation_table_collapse#:#Collapse All ui#:#presentation_table_expand#:#Expand All +ui#:#prompt_request_error#:#Something went wrong when loading the contents of this dialogue. Please try again later or contact your system administrators. ui#:#rating_average#:#Others rated %s of 5 ui#:#reset_stars#:#neutral ui#:#select_node#:#Add node %s to selection diff --git a/templates/default/070-components/UI-framework/Entity/_ui-component_entity.scss b/templates/default/070-components/UI-framework/Entity/_ui-component_entity.scss index f089acabb44f..9cab19f942ff 100755 --- a/templates/default/070-components/UI-framework/Entity/_ui-component_entity.scss +++ b/templates/default/070-components/UI-framework/Entity/_ui-component_entity.scss @@ -9,6 +9,11 @@ $entity-padding-horizontal: l.$il-padding-xlarge-horizontal; // counterstyling i $img-large-max-width: 360px; $max-rows: 9; +$prompt-gap: 0.75rem; +$prompt-symbol-size: 1.5rem; +$prompt-primary-identifier-width: 10rem; +$prompt-font-weight: s.$il-font-weight-base; + @mixin entity-limited-space-design() { &__container { display: flex; @@ -123,3 +128,135 @@ $max-rows: 9; @include entity-limited-space-design(); } } + +// compact entity for confirmation prompts +.c-prompt .c-listing-entity .c-entity { + + &__container { + // beats the base grid and the small-screen block/flex-column variants + display: flex; + flex-direction: row; + align-items: center; + gap: $prompt-gap; + padding: 0.25rem 0.5rem; + //line-height: 1.3; + + // reset half-paddings, order and the per-block font sizes + // (base styles set larger/smaller sizes on details, main-details, ...) + > * { + padding: 0; + order: 0; + font-size: inherit; + min-width: 0; // allow ellipsis inside flex + } + } + + &__secondary-identifier { + order: -1; // always left, regardless of DOM order + flex: 0 0 auto; + width: auto; + max-width: none; + + &.--string, + &.--shy, + &.--shylink { + width: auto; + max-width: 8rem; + } + + &.--symbol { + line-height: 0; + --prompt-avatar-size: #{$prompt-symbol-size}; + + .il-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--prompt-avatar-size); + height: var(--prompt-avatar-size); + min-width: var(--prompt-avatar-size); + min-height: var(--prompt-avatar-size); + line-height: 1; + font-size: calc(var(--prompt-avatar-size) * 0.5); + + .abbreviation { + font-size: inherit; + line-height: 1; + letter-spacing: 0; + margin: 0; + padding: 0; + transform: none; + position: static; + } + } + } + + &.--image img { + width: $prompt-symbol-size; + height: $prompt-symbol-size; + max-width: none; + margin: 0; + object-fit: cover; + } + } + + &__featured-headerbar { + flex: 0 1 $prompt-primary-identifier-width; + + // flatten the bar so the ellipsis can work on the name + .l-bar__space-keeper, + .l-bar__group, + .l-bar__element { + display: block; + min-width: 0; + } + + // empty action slot must not eat space + .c-entity__actions-container:not(:has(*)) { + display: none; + } + } + + &__primary-identifier { + font-size: inherit; + font-weight: s.$il-font-weight-bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__main-details { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: $prompt-font-weight; + + // flatten the property bars to inline text + .c-listing-property, + .c-listing-property__property, + .c-listing-property__propertylabel, + .c-listing-property__propertyvalue { + display: inline; + margin: 0; + padding: 0; + font-size: inherit; + } + + .c-listing-property__property + .c-listing-property__property { + margin-left: $prompt-gap; + } + + .c-listing-property__propertylabel { + font-weight: $prompt-font-weight; + margin-right: 0.25em; + } + + .c-listing-property__propertyvalue { + font-weight: $prompt-font-weight; + } + } + + &__main-details { + flex: 1 1 0; // takes the remaining width + } +} diff --git a/templates/default/070-components/UI-framework/Listing/_ui-component_entity_listing.scss b/templates/default/070-components/UI-framework/Listing/_ui-component_entity_listing.scss new file mode 100755 index 000000000000..d66fe60d0839 --- /dev/null +++ b/templates/default/070-components/UI-framework/Listing/_ui-component_entity_listing.scss @@ -0,0 +1,52 @@ +@use "../../../050-layout/basics" as l; +@use "../../../050-layout/layout_grid-auto-columns" as l-auto-grid; +@use "../../../030-tools/tool_multi-line-cap" as t-linecap; + +// so we can force entity small space design on large screens +@use "../../../070-components/UI-framework/Entity/ui-component_entity" as entity; + +$listing-column-min-width: 650px; +$grid-column-min-width: 400px; + +.c-listing-entity { + + list-style: none; + padding-left: 0; + + &--standard { + @include l-auto-grid.make-grid-with-auto-columns($listing-column-min-width); + } + + &--grid { + @include l-auto-grid.make-grid-with-auto-columns($grid-column-min-width); + .c-entity { + @include entity.entity-limited-space-design(); + &__container { + height: 100%; + .c-listing-property__propertyvalue:has(.t-text-more-less__toggle:not(:checked)) .t-text-more-less__text-body { + height: 2lh; + } + } + } + } +} + +.c-prompt { + + .c-listing-entity { + + @include l-auto-grid.make-grid-with-auto-columns($listing-column-min-width, 0); + + // remove touching borders inside listing + li + li > .c-entity__container { + border-top: 0; + } + + .c-entity { + .--symbol { + max-width: 32px; + max-height: 32px; + } + } + } +} diff --git a/templates/default/070-components/UI-framework/Listing/_ui-component_entitylisting.scss b/templates/default/070-components/UI-framework/Listing/_ui-component_entitylisting.scss deleted file mode 100755 index 549919a3a6b7..000000000000 --- a/templates/default/070-components/UI-framework/Listing/_ui-component_entitylisting.scss +++ /dev/null @@ -1,33 +0,0 @@ -@use "../../../050-layout/basics" as l; -@use "../../../050-layout/layout_grid-auto-columns" as l-auto-grid; -@use "../../../030-tools/tool_multi-line-cap" as t-linecap; - -// so we can force entity small space design on large screens -@use "../../../070-components/UI-framework/Entity/ui-component_entity" as entity; - -$listing-column-min-width: 650px; -$grid-column-min-width: 400px; - -.c-listing-entity, -.c-listing-entity-grid { - list-style: none; - padding-left: 0; -} - -.c-listing-entity { - @include l-auto-grid.make-grid-with-auto-columns($listing-column-min-width); -} - -.c-listing-entity-grid { - @include l-auto-grid.make-grid-with-auto-columns($grid-column-min-width); - .c-entity { - @include entity.entity-limited-space-design(); - &__container { - height: 100%; - .c-listing-property__propertyvalue:has(.t-text-more-less__toggle:not(:checked)) .t-text-more-less__text-body { - height: 2lh; - } - } - } -} - diff --git a/templates/default/070-components/UI-framework/Modal/_ui-component_modal.scss b/templates/default/070-components/UI-framework/Modal/_ui-component_modal.scss index 4a704fa0b121..5c1f6187dd7e 100755 --- a/templates/default/070-components/UI-framework/Modal/_ui-component_modal.scss +++ b/templates/default/070-components/UI-framework/Modal/_ui-component_modal.scss @@ -40,7 +40,7 @@ $il-modal-dark-carousel-color: $il-main-bg !default; // -------------------------------------------------- .c-modal, -.il-prompt dialog { +.c-prompt dialog { border: none; background: none; @include box-shadow($il-shadow--large); @@ -150,7 +150,7 @@ $il-modal-dark-carousel-color: $il-main-bg !default; } // Actual modal - .modal-content, .il-prompt dialog { + .modal-content, .c-prompt dialog { position: relative; background-color: $modal-content-bg; background-clip: padding-box; @@ -162,7 +162,7 @@ $il-modal-dark-carousel-color: $il-main-bg !default; outline: 0; } - .il-prompt dialog hr { + .c-prompt dialog hr { margin: 0; } @@ -181,11 +181,11 @@ $il-modal-dark-carousel-color: $il-main-bg !default; } .modal-header, - .il-prompt__header, + .c-prompt__header, .modal-body, - .il-prompt__contents, + .c-prompt__contents, .modal-footer, - .il-prompt__buttons { + .c-prompt__buttons { padding: $modal-title-padding; @include clearfix; } @@ -221,24 +221,18 @@ $il-modal-dark-carousel-color: $il-main-bg !default; } // Footer (for actions) - .modal-footer { + .modal-footer, + .c-prompt__buttons { text-align: right; // right align buttons border-top: 1px solid $modal-footer-border-color; @include clearfix; // clear it in case folks use .pull-* classes on buttons - // Properly space out buttons - .btn + .btn { - margin-bottom: 0; // account for input[type="submit"] which gets the bottom margin like all other inputs - margin-left: 5px; - } - // but override that for button groups - .btn-group .btn + .btn { - margin-left: -1px; - } - // and override it for block buttons as well - .btn-block + .btn-block { - margin-left: 0; - } + display: flex; + justify-content: flex-end; + gap: 5px; + + // ensure no spacing is applied other than gap + .btn { margin: 0; } } // Measure scrollbar width for padding body during modal show/hide @@ -275,7 +269,7 @@ $il-modal-dark-carousel-color: $il-main-bg !default; // end of section based on bootstrap 3 // modal sizes responsive and variants -.c-modal, .il-prompt { +.c-modal, .c-prompt { @include on-screen-size(small) { margin: 0; padding: 0; diff --git a/templates/default/070-components/_index.scss b/templates/default/070-components/_index.scss index 7e6c2de78437..2f40490d398e 100755 --- a/templates/default/070-components/_index.scss +++ b/templates/default/070-components/_index.scss @@ -29,7 +29,7 @@ @use "./UI-framework/Listing/ui-component_properties"; @use "./UI-framework/Listing/_ui-component_characteristic_value.scss"; @use "./UI-framework/Listing/_ui-component_workflow.scss"; -@use "./UI-framework/Listing/_ui-component_entitylisting.scss"; +@use "./UI-framework/Listing/_ui-component_entity_listing.scss"; @use "./UI-framework/MainControls/_ui-component_maincontrols.scss"; @use "./UI-framework/Menu/_ui-component_menu.scss"; @use "./UI-framework/Input/_ui-component_input.scss"; diff --git a/templates/default/070-components/legacy/Modules/_component_test.scss b/templates/default/070-components/legacy/Modules/_component_test.scss index 3d082718e745..bacd6b408b64 100755 --- a/templates/default/070-components/legacy/Modules/_component_test.scss +++ b/templates/default/070-components/legacy/Modules/_component_test.scss @@ -177,7 +177,7 @@ $cons-scoring-bottom-fade-height: $il-padding-xxxlarge-vertical * 2; .solutionbox { width: auto; } - + .ilc_answers.answers.ilAssClozeTest div input[type=text] { width: 100%; } @@ -249,7 +249,7 @@ $cons-scoring-bottom-fade-height: $il-padding-xxxlarge-vertical * 2; // this is where the actual editing of the feedback takes place // suggestion for UI component: Guided Prompt or Guided Form, placing a form besides relevant content to be evaluated // -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] { display: grid; grid-template-areas: "answer btn" "answer feedback"; @@ -266,7 +266,7 @@ $cons-scoring-bottom-fade-height: $il-padding-xxxlarge-vertical * 2; display: none; } - .il-prompt__header { + .c-prompt__header { grid-area: answer; // we are using the entire title as the left side position: relative; // so fade mixin is attached to the bottom form { @@ -274,12 +274,12 @@ $cons-scoring-bottom-fade-height: $il-padding-xxxlarge-vertical * 2; display: none; } - .il-prompt__title { + .c-prompt__title { display: flex; flex-direction: column; } - .il-prompt__title, + .c-prompt__title, .c-consecutive-scoring_sanswer, .c-consecutive-scoring__answer__scroll-container { height: 100%; // fill modal completely @@ -304,12 +304,12 @@ $cons-scoring-bottom-fade-height: $il-padding-xxxlarge-vertical * 2; } } - .il-prompt__contents { + .c-prompt__contents { grid-area: feedback; overflow: auto; } - .il-prompt__buttons { + .c-prompt__buttons { grid-area: btn; justify-self: end; } @@ -386,7 +386,7 @@ $cons-scoring-bottom-fade-height: $il-padding-xxxlarge-vertical * 2; // Change layout of Markdown Form Field in Single View and in Prompt Modal .c-sequence__segment:has(.c-layout-alignment--horizontal-evenly .c-consecutive-scoring__answer), -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__contents { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__contents { form { .c-input[data-il-ui-component="markdown-field-input"] { display: block; diff --git a/templates/default/delos.css b/templates/default/delos.css index b32acb4c1207..c80434c0741e 100644 --- a/templates/default/delos.css +++ b/templates/default/delos.css @@ -5189,6 +5189,108 @@ hr.il-divider-with-label { } } +.c-prompt .c-listing-entity .c-entity__container { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + padding: 0.25rem 0.5rem; +} +.c-prompt .c-listing-entity .c-entity__container > * { + padding: 0; + order: 0; + font-size: inherit; + min-width: 0; +} +.c-prompt .c-listing-entity .c-entity__secondary-identifier { + order: -1; + flex: 0 0 auto; + width: auto; + max-width: none; +} +.c-prompt .c-listing-entity .c-entity__secondary-identifier.--string, .c-prompt .c-listing-entity .c-entity__secondary-identifier.--shy, .c-prompt .c-listing-entity .c-entity__secondary-identifier.--shylink { + width: auto; + max-width: 8rem; +} +.c-prompt .c-listing-entity .c-entity__secondary-identifier.--symbol { + line-height: 0; + --prompt-avatar-size: 1.5rem; +} +.c-prompt .c-listing-entity .c-entity__secondary-identifier.--symbol .il-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--prompt-avatar-size); + height: var(--prompt-avatar-size); + min-width: var(--prompt-avatar-size); + min-height: var(--prompt-avatar-size); + line-height: 1; + font-size: calc(var(--prompt-avatar-size) * 0.5); +} +.c-prompt .c-listing-entity .c-entity__secondary-identifier.--symbol .il-avatar .abbreviation { + font-size: inherit; + line-height: 1; + letter-spacing: 0; + margin: 0; + padding: 0; + transform: none; + position: static; +} +.c-prompt .c-listing-entity .c-entity__secondary-identifier.--image img { + width: 1.5rem; + height: 1.5rem; + max-width: none; + margin: 0; + object-fit: cover; +} +.c-prompt .c-listing-entity .c-entity__featured-headerbar { + flex: 0 1 10rem; +} +.c-prompt .c-listing-entity .c-entity__featured-headerbar .l-bar__space-keeper, +.c-prompt .c-listing-entity .c-entity__featured-headerbar .l-bar__group, +.c-prompt .c-listing-entity .c-entity__featured-headerbar .l-bar__element { + display: block; + min-width: 0; +} +.c-prompt .c-listing-entity .c-entity__featured-headerbar .c-entity__actions-container:not(:has(*)) { + display: none; +} +.c-prompt .c-listing-entity .c-entity__primary-identifier { + font-size: inherit; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.c-prompt .c-listing-entity .c-entity__main-details { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 400; +} +.c-prompt .c-listing-entity .c-entity__main-details .c-listing-property, +.c-prompt .c-listing-entity .c-entity__main-details .c-listing-property__property, +.c-prompt .c-listing-entity .c-entity__main-details .c-listing-property__propertylabel, +.c-prompt .c-listing-entity .c-entity__main-details .c-listing-property__propertyvalue { + display: inline; + margin: 0; + padding: 0; + font-size: inherit; +} +.c-prompt .c-listing-entity .c-entity__main-details .c-listing-property__property + .c-listing-property__property { + margin-left: 0.75rem; +} +.c-prompt .c-listing-entity .c-entity__main-details .c-listing-property__propertylabel { + font-weight: 400; + margin-right: 0.25em; +} +.c-prompt .c-listing-entity .c-entity__main-details .c-listing-property__propertyvalue { + font-weight: 400; +} +.c-prompt .c-listing-entity .c-entity__main-details { + flex: 1 1 0; +} + .c-listing-inline { padding: 0; margin: 0; @@ -6269,49 +6371,59 @@ a[aria-disabled].il-link.link-bulky:hover { padding: 15px 15px; } -.c-listing-entity, -.c-listing-entity-grid { +.c-listing-entity { list-style: none; padding-left: 0; } - -.c-listing-entity { +.c-listing-entity--standard { display: grid; grid-template-columns: repeat(auto-fill, minmax(650px, 1fr)); gap: 15px; } - -.c-listing-entity-grid { +.c-listing-entity--grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 15px; } -.c-listing-entity-grid .c-entity__container { +.c-listing-entity--grid .c-entity__container { display: flex; flex-direction: column; } -.c-listing-entity-grid .c-entity__container > * { +.c-listing-entity--grid .c-entity__container > * { order: 2; } -.c-listing-entity-grid .c-entity__secondary-identifier { +.c-listing-entity--grid .c-entity__secondary-identifier { order: 1; } -.c-listing-entity-grid .c-entity__secondary-identifier.--image img { +.c-listing-entity--grid .c-entity__secondary-identifier.--image img { width: calc(100% + 30px); max-width: unset; margin-left: -15px; margin-top: -9px; } -.c-listing-entity-grid .c-entity__reactionbar { +.c-listing-entity--grid .c-entity__reactionbar { flex-grow: 1; } -.c-listing-entity-grid .c-entity__container { +.c-listing-entity--grid .c-entity__container { height: 100%; } -.c-listing-entity-grid .c-entity__container .c-listing-property__propertyvalue:has(.t-text-more-less__toggle:not(:checked)) .t-text-more-less__text-body { +.c-listing-entity--grid .c-entity__container .c-listing-property__propertyvalue:has(.t-text-more-less__toggle:not(:checked)) .t-text-more-less__text-body { height: 2lh; } +.c-prompt .c-listing-entity { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(650px, 1fr)); + gap: 0; +} +.c-prompt .c-listing-entity li + li > .c-entity__container { + border-top: 0; +} +.c-prompt .c-listing-entity .c-entity .--symbol { + max-width: 32px; + max-height: 32px; +} + .il-maincontrols-slate.disengaged { display: none; } @@ -9834,36 +9946,36 @@ div.alert ul { } .c-modal, -.il-prompt dialog { +.c-prompt dialog { border: none; background: none; -webkit-box-shadow: 20px 10px 30px rgba(0, 0, 0, 0.15); box-shadow: 20px 10px 30px rgba(0, 0, 0, 0.15); } .c-modal.il-modal-lightbox-dark .modal-content, -.il-prompt dialog.il-modal-lightbox-dark .modal-content { +.c-prompt dialog.il-modal-lightbox-dark .modal-content { background-color: #2c2c2c; color: white; } .c-modal.il-modal-lightbox-dark .modal-content .modal-header, -.il-prompt dialog.il-modal-lightbox-dark .modal-content .modal-header { +.c-prompt dialog.il-modal-lightbox-dark .modal-content .modal-header { border-bottom-color: white; } .c-modal.il-modal-lightbox-dark .modal-content .modal-title, -.il-prompt dialog.il-modal-lightbox-dark .modal-content .modal-title { +.c-prompt dialog.il-modal-lightbox-dark .modal-content .modal-title { padding: 0px; } .c-modal.il-modal-lightbox-dark .modal-content .carousel-inner, -.il-prompt dialog.il-modal-lightbox-dark .modal-content .carousel-inner { +.c-prompt dialog.il-modal-lightbox-dark .modal-content .carousel-inner { padding: 0px; } .c-modal.il-modal-lightbox-dark .modal-content .carousel-control, -.il-prompt dialog.il-modal-lightbox-dark .modal-content .carousel-control { +.c-prompt dialog.il-modal-lightbox-dark .modal-content .carousel-control { color: white; width: 15%; } .c-modal.il-modal-lightbox-dark .close, -.il-prompt dialog.il-modal-lightbox-dark .close { +.c-prompt dialog.il-modal-lightbox-dark .close { color: white; background-color: #2c2c2c; opacity: 1; @@ -9941,7 +10053,7 @@ div.alert ul { margin: 10px; } -.modal-content, .il-prompt dialog { +.modal-content, .c-prompt dialog { position: relative; background-color: #fff; background-clip: padding-box; @@ -9951,7 +10063,7 @@ div.alert ul { outline: 0; } -.il-prompt dialog hr { +.c-prompt dialog hr { margin: 0; } @@ -9974,19 +10086,19 @@ div.alert ul { } .modal-header, -.il-prompt__header, +.c-prompt__header, .modal-body, -.il-prompt__contents, +.c-prompt__contents, .modal-footer, -.il-prompt__buttons { +.c-prompt__buttons { padding: 9px 15px; } .modal-header::after, -.il-prompt__header::after, +.c-prompt__header::after, .modal-body::after, -.il-prompt__contents::after, +.c-prompt__contents::after, .modal-footer::after, -.il-prompt__buttons::after { +.c-prompt__buttons::after { display: block; clear: both; content: ""; @@ -10014,24 +10126,26 @@ div.alert ul { margin-top: 0; } -.modal-footer { +.modal-footer, +.c-prompt__buttons { text-align: right; border-top: 1px solid #e5e5e5; } -.modal-footer::after { +.modal-footer::after, +.c-prompt__buttons::after { display: block; clear: both; content: ""; } -.modal-footer .btn + .btn { - margin-bottom: 0; - margin-left: 5px; -} -.modal-footer .btn-group .btn + .btn { - margin-left: -1px; +.modal-footer, +.c-prompt__buttons { + display: flex; + justify-content: flex-end; + gap: 5px; } -.modal-footer .btn-block + .btn-block { - margin-left: 0; +.modal-footer .btn, +.c-prompt__buttons .btn { + margin: 0; } .modal-scrollbar-measure { @@ -10061,7 +10175,7 @@ div.alert ul { } } @media screen and (max-width: 768px) { - .c-modal, .il-prompt { + .c-modal, .c-prompt { margin: 0; padding: 0; max-width: 100vw; @@ -10069,20 +10183,20 @@ div.alert ul { } } .c-modal .modal-dialog, -.c-modal dialog, .il-prompt .modal-dialog, -.il-prompt dialog { +.c-modal dialog, .c-prompt .modal-dialog, +.c-prompt dialog { width: 70vw; width: 70svw; } .c-modal .modal-dialog.modal-sm, -.c-modal dialog.modal-sm, .il-prompt .modal-dialog.modal-sm, -.il-prompt dialog.modal-sm { +.c-modal dialog.modal-sm, .c-prompt .modal-dialog.modal-sm, +.c-prompt dialog.modal-sm { width: 40vw; width: 40svw; } .c-modal .modal-dialog.modal-lg, -.c-modal dialog.modal-lg, .il-prompt .modal-dialog.modal-lg, -.il-prompt dialog.modal-lg { +.c-modal dialog.modal-lg, .c-prompt .modal-dialog.modal-lg, +.c-prompt dialog.modal-lg { width: 80vw; width: 80svw; } @@ -10090,10 +10204,10 @@ div.alert ul { .c-modal .modal-dialog, .c-modal .modal-dialog.modal-sm, .c-modal .modal-dialog.modal-lg, .c-modal dialog, .c-modal dialog.modal-sm, - .c-modal dialog.modal-lg, .il-prompt .modal-dialog, .il-prompt .modal-dialog.modal-sm, .il-prompt .modal-dialog.modal-lg, - .il-prompt dialog, - .il-prompt dialog.modal-sm, - .il-prompt dialog.modal-lg { + .c-modal dialog.modal-lg, .c-prompt .modal-dialog, .c-prompt .modal-dialog.modal-sm, .c-prompt .modal-dialog.modal-lg, + .c-prompt dialog, + .c-prompt dialog.modal-sm, + .c-prompt dialog.modal-lg { width: 100vw; width: 100svw; max-height: 100vh; @@ -17260,7 +17374,7 @@ div.ilc_Page.readonly textarea[disabled] { width: 100%; } } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] { display: grid; grid-template-areas: "answer btn" "answer feedback"; grid-template-rows: max-content 1fr; @@ -17269,39 +17383,39 @@ div.ilc_Page.readonly textarea[disabled] { width: 1200px; } @media screen and (max-width: 768px) { - .panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] { + .panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] { display: block; } } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] hr { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] hr { display: none; } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__header { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__header { grid-area: answer; position: relative; } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__header form { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__header form { display: none; } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__header .il-prompt__title { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__header .c-prompt__title { display: flex; flex-direction: column; } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__header .il-prompt__title, -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__header .c-consecutive-scoring_sanswer, -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__header .c-consecutive-scoring__answer__scroll-container { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__header .c-prompt__title, +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__header .c-consecutive-scoring_sanswer, +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__header .c-consecutive-scoring__answer__scroll-container { height: 100%; } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__header .c-consecutive-scoring__answer { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__header .c-consecutive-scoring__answer { flex-direction: column-reverse; max-height: unset; min-height: 0; } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__header .c-consecutive-scoring__answer__scroll-container { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__header .c-consecutive-scoring__answer__scroll-container { min-height: 0; width: 100%; } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__header .c-consecutive-scoring__answer__grade-card { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__header .c-consecutive-scoring__answer__grade-card { padding: 0; margin: 0; margin-top: 15px; @@ -17309,11 +17423,11 @@ div.ilc_Page.readonly textarea[disabled] { border: none; width: 100%; } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__contents { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__contents { grid-area: feedback; overflow: auto; } -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__buttons { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__buttons { grid-area: btn; justify-self: end; } @@ -17363,11 +17477,11 @@ div.ilc_Page.readonly textarea[disabled] { } .c-sequence__segment:has(.c-layout-alignment--horizontal-evenly .c-consecutive-scoring__answer) form .c-input[data-il-ui-component=markdown-field-input], -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__contents form .c-input[data-il-ui-component=markdown-field-input] { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__contents form .c-input[data-il-ui-component=markdown-field-input] { display: block; } .c-sequence__segment:has(.c-layout-alignment--horizontal-evenly .c-consecutive-scoring__answer) form .c-input[data-il-ui-component=markdown-field-input] .c-field-markdown textarea, -.panel:has(.c-consecutive-scoring__answer) ~ .il-prompt > dialog[open] .il-prompt__contents form .c-input[data-il-ui-component=markdown-field-input] .c-field-markdown textarea { +.panel:has(.c-consecutive-scoring__answer) ~ .c-prompt > dialog[open] .c-prompt__contents form .c-input[data-il-ui-component=markdown-field-input] .c-field-markdown textarea { height: 50vh; }