diff --git a/components/ILIAS/UI/UI.php b/components/ILIAS/UI/UI.php index 1fd13c1e888f..e3a901931017 100644 --- a/components/ILIAS/UI/UI.php +++ b/components/ILIAS/UI/UI.php @@ -466,12 +466,13 @@ public function init( $internal[UI\Implementation\Component\Entity\Factory::class] = static fn() => new UI\Implementation\Component\Entity\Factory(); + $internal[UI\Implementation\Component\Prompt\State\Factory::class] = static fn() => + new UI\Implementation\Component\Prompt\State\Factory(); $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(); $internal[UI\Implementation\Component\Navigation\Factory::class] = static fn() => new UI\Implementation\Component\Navigation\Factory( @@ -551,6 +552,16 @@ public function init( $use[UI\HelpTextRetriever::class], $internal[UI\Implementation\Component\Input\UploadLimitResolver::class], ), + new UI\Implementation\Component\Entity\EntityRendererFactory( + $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\Listing\ListingRendererFactory( $use[UI\Implementation\FactoryInternal::class], $internal[UI\Implementation\Render\TemplateFactory::class], 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..330a080feec7 --- /dev/null +++ b/components/ILIAS/UI/src/Component/Entity/EntityRetrieval.php @@ -0,0 +1,61 @@ + + */ + 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 Prompt State to confirm a subset of entities. + * + * @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/Listing/Entity/Mapping.php b/components/ILIAS/UI/src/Component/Input/Field/Entity.php old mode 100755 new mode 100644 similarity index 64% rename from components/ILIAS/UI/src/Component/Listing/Entity/Mapping.php rename to components/ILIAS/UI/src/Component/Input/Field/Entity.php index ea0f31764cf3..14b47a0abf7d --- a/components/ILIAS/UI/src/Component/Listing/Entity/Mapping.php +++ b/components/ILIAS/UI/src/Component/Input/Field/Entity.php @@ -18,14 +18,15 @@ declare(strict_types=1); -namespace ILIAS\UI\Component\Listing\Entity; +namespace ILIAS\UI\Component\Input\Field; -use ILIAS\UI\Component\Entity\Entity; +use ILIAS\UI\Component\Entity\EntityRetrieval; +use ILIAS\UI\Component\Input\Container\Form\FormInput; /** - * Hand a record over to RecordToEntity and factor an Entity + * Carries entity identifiers through a form roundtrip and visualises them. */ -interface Mapping +interface Entity extends FormInput { - public function map(mixed $record): Entity; + public function getEntityRetrieval(): EntityRetrieval; } diff --git a/components/ILIAS/UI/src/Component/Input/Field/Factory.php b/components/ILIAS/UI/src/Component/Input/Field/Factory.php index 3c9c4294d376..535396894aba 100755 --- a/components/ILIAS/UI/src/Component/Input/Field/Factory.php +++ b/components/ILIAS/UI/src/Component/Input/Field/Factory.php @@ -22,6 +22,7 @@ use ILIAS\UI\Component\Input\Container\Form\FormInput; use ILIAS\UI\Component\Input\Field\Node\NodeRetrieval; +use ILIAS\UI\Component\Entity\EntityRetrieval; use ILIAS\Data\ImagePurpose; /** @@ -809,6 +810,48 @@ public function link(string $label, ?string $byline = null): Link; */ public function hidden(): Hidden; + /** + * --- + * description: + * purpose: > + * The Entity Input carries technical identifiers of entities through a + * form roundtrip and visualises those entities. Users must not operate + * this input. Typical context is a confirmation prompt: a Standard Form + * in a Prompt State contains this input (withValue of the affected ids) + * so the posted payload and the shown entities cannot diverge. Additional + * operable fields (e.g. type the object title) can sit in the same form. + * A confirmation Message Box as sibling Prompt content depends on + * secondary Prompt content (follow-up to GitHub issue 11105: + * show($primary, ...$secondary)). + * composition: > + * An unordered list of Entity components, each paired with a Hidden Input + * generated via withValue() (HasDynamicInputs). There is no user control. + * effect: > + * Submitting the enclosing form posts the entity ids in the request body. + * Consumers read them from the form getData() for this field. + * rivals: + * Hidden: Hidden transmits a value without visualisation. Entity Input also shows the entities. + * Entity Listing: Entity Listing is a listing, not a form field, and has its own retrieval lifecycle. + * + * context: + * - The Entity Input is used in UI-forms, especially confirmation prompts. + * + * rules: + * usage: + * 1: > + * The Entity Input MUST NOT be operated by the user. + * 2: > + * Consumers MUST set the identifiers with withValue() before rendering + * and MUST read them from the enclosing form getData() after withRequest(). + * 3: > + * Entity ids MUST be posted in the request body, never attached to the + * form action URL. + * + * --- + * @return \ILIAS\UI\Component\Input\Field\Entity + */ + public function entity(EntityRetrieval $entity_retrieval): Entity; + /** * --- * description: 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/EntityListing.php old mode 100755 new mode 100644 index 6f5c98022c95..d92cedebf9f1 --- a/components/ILIAS/UI/src/Component/Listing/Entity/EntityListing.php +++ b/components/ILIAS/UI/src/Component/Listing/Entity/EntityListing.php @@ -23,14 +23,8 @@ use ILIAS\UI\Component\Component; /** - * This is what an EntityListings looks like + * This is what an Entity Listing looks like. */ interface EntityListing 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. - */ - public function withData(DataRetrieval $data): self; } 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/RecordToEntity.php b/components/ILIAS/UI/src/Component/Listing/Entity/RecordToEntity.php deleted file mode 100755 index 392df7e1cd87..000000000000 --- a/components/ILIAS/UI/src/Component/Listing/Entity/RecordToEntity.php +++ /dev/null @@ -1,32 +0,0 @@ -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/EntityFieldContextRenderer.php b/components/ILIAS/UI/src/Implementation/Component/Entity/EntityFieldContextRenderer.php new file mode 100644 index 000000000000..964df58bb22c --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Component/Entity/EntityFieldContextRenderer.php @@ -0,0 +1,70 @@ +isCompactListItem($component)) { + return $this->renderCompactEntity($component, $default_renderer); + } + + return parent::render($component, $default_renderer); + } + + protected function renderCompactEntity( + Component\Entity\Entity $component, + RendererInterface $default_renderer + ): string { + $tpl = $this->getTemplate('tpl.entity-brief.html', true, true); + + $primary_identifier = $component->getPrimaryIdentifier(); + $content = is_string($primary_identifier) + ? $primary_identifier + : $default_renderer->render($primary_identifier); + + $tpl->setVariable('CONTENT', $content); + + return $tpl->get(); + } + + protected function isCompactListItem(Entity $component): bool + { + return $component->getBlockingAvailabilityConditions() === [] && + $component->getFeaturedProperties() === [] && + $component->getMainDetails() === [] && + $component->getDetails() === [] && + $component->getAvailability() === [] && + $component->getPersonalStatus() === [] && + $component->getPrioritizedReactions() === [] && + $component->getReactions() === [] && + $component->getManagingActions() === [] && + $component->getWorkflow() === null && + $component->getSecondaryIdentifier() === ''; + } +} 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..790ff3ee75aa --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Component/Entity/EntityRendererFactory.php @@ -0,0 +1,58 @@ +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 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 + ); + } +} 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/Input/Field/Entity.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Entity.php new file mode 100644 index 000000000000..baa01b8639f0 --- /dev/null +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Entity.php @@ -0,0 +1,86 @@ +entity_retrieval; + } + + public function getUpdateOnLoadCode(): Closure + { + return static fn($id) => ''; + } + + protected function getConstraintForRequirement(): ?Constraint + { + if ($this->requirement_constraint !== null) { + return $this->requirement_constraint; + } + + return $this->refinery->custom()->constraint( + static fn($value) => is_array($value) && 1 <= count($value), + sprintf($this->language->txt('not_greater_than_or_equal'), 1) + ); + } + + protected function isClientSideValueOk($value): bool + { + if (!is_array($value)) { + return false; + } + foreach ($value as $_value) { + if (!is_string($_value) && !is_int($_value)) { + return false; + } + } + + return parent::isClientSideValueOk($value); + } +} 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..1a3f39fce13f 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,19 @@ public function hidden(): Hidden return new Hidden($this->data_factory, $this->refinery); } + public function entity(\ILIAS\UI\Component\Entity\EntityRetrieval $entity_retrieval): Entity + { + return new Entity( + $this->lng, + $this->data_factory, + $this->refinery, + $this->hidden(), + $entity_retrieval, + '', + null, + ); + } + 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/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php index a3f6c9a6ea6f..760123da5971 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php @@ -38,6 +38,7 @@ use ILIAS\Data\FiveStarRatingScale; use ILIAS\UI\Implementation\Component\Input\Container\Filter\ProxyFilterField; use ILIAS\Data\URI; +use ILIAS\Data\Order; use ILIAS\UI\Implementation\Component\ComponentHelper; /** @@ -157,6 +158,9 @@ public function render(Component\Component $component, RendererInterface $defaul case ($component instanceof F\Hidden): return $this->renderHiddenField($component); + case ($component instanceof F\Entity): + return $this->renderEntityField($component, $default_renderer); + case ($component instanceof F\ColorSelect): return $this->renderColorSelectField($component, $default_renderer); @@ -934,6 +938,28 @@ protected function renderHiddenField(F\Hidden $input): string return $template->get(); } + protected function renderEntityField(F\Entity $component, RendererInterface $default_renderer): string + { + $template = $this->getTemplate('tpl.entity.html', true, true); + + $dynamic_inputs = (static fn() => yield from $component->getGeneratedDynamicInputs())(); + $entities = $component->getEntityRetrieval()->getEntitiesByIds( + $this->getUIFactory(), + new Order('id', Order::ASC), + array_values($component->getValue()), + ); + + foreach ($this->iterateGeneratorsInLockstep($entities, $dynamic_inputs) as [$entity, $dynamic_input]) { + $this->checkArgInstanceOf('entity', $entity, Component\Entity\Entity::class); + $template->setCurrentBlock('entity'); + $template->setVariable('ENTITY', $default_renderer->render($entity)); + $template->setVariable('INPUT', $default_renderer->render($dynamic_input)); + $template->parseCurrentBlock(); + } + + return $this->wrapInFormContext($component, $component->getLabel(), $template->get()); + } + /** * @inheritdoc */ diff --git a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/EntityListing.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/EntityListing.php index ff840cdb2f28..06df12bac733 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/EntityListing.php +++ b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/EntityListing.php @@ -24,48 +24,38 @@ use ILIAS\UI\Component\Entity as IEntity; use ILIAS\UI\Implementation\Component\ComponentHelper; use ILIAS\Data\Range; +use ILIAS\Data\Order; abstract class EntityListing implements I\EntityListing { use ComponentHelper; - protected I\DataRetrieval $data; - public function __construct( - protected I\RecordToEntity $entity_mapping + protected IEntity\EntityRetrieval $entity_retrieval ) { } - public function withData(I\DataRetrieval $data): self - { - $clone = clone $this; - $clone->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 + ?Order $order = null, + mixed $additional_viewcontrol_data = null, + mixed $filter_data = null, + mixed $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); + $range = $range ?? new Range(0, PHP_INT_MAX); + $order = $order ?? new Order('id', Order::ASC); + + yield from $this->entity_retrieval->getEntities( + $ui_factory, + $range, + $order, + $additional_viewcontrol_data, + $filter_data, + $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/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Listing/Entity/Renderer.php index 192769e8091a..6068a0abc0dd 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 { @@ -55,6 +53,7 @@ protected function renderEntityListingStandard(EntityListing $component, Rendere } return $tpl->get(); } + protected function renderEntityListingGrid(EntityListing $component, RendererInterface $default_renderer): string { $tpl = $this->getTemplate('tpl.entitylistinggrid.html', true, true); diff --git a/components/ILIAS/UI/src/Implementation/Component/Prompt/Factory.php b/components/ILIAS/UI/src/Implementation/Component/Prompt/Factory.php index da21193aa8f7..158f77df5d13 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/Factory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/Factory.php @@ -20,14 +20,15 @@ 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, ) { } @@ -38,6 +39,6 @@ public function standard(URI $async_url): Standard public function state(): State\Factory { - return new State\Factory(); + return $this->state_factory; } } 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 ebd99108be1e..cc6d898ab241 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Factory.php +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Factory.php @@ -20,9 +20,8 @@ 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\Prompt as I; class Factory implements I\State\Factory { @@ -42,5 +41,4 @@ public function redirect(URI $redirect): State return (new State(null)) ->withRedirect($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 d7c22257d2b5..be8c26a9ff93 100644 --- a/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Prompt/State/Renderer.php @@ -21,8 +21,6 @@ namespace ILIAS\UI\Implementation\Component\Prompt\State; 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; @@ -40,7 +38,6 @@ 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); @@ -59,28 +56,37 @@ protected function renderState(State $component, RendererInterface $default_rend } $tpl->setVariable('CONTENT', $default_renderer->render($content_component)); - $tpl->setVariable('TITLE', $component->getTitle()); - $buttons = $component->getButtons(); + if ($content_component instanceof \ILIAS\UI\Component\Input\Container\Form\Form) { - $submit_button = $this->getUIFactory()->button()->standard( - $content_component->getSubmitLabel() ?? $this->txt("save"), - $content_component->getSubmitSignal() - ); - $buttons[] = $submit_button; + $buttons[] = $this->getFormSubmitButton($content_component); } - $buttons[] = $this->getUIFactory()->button() - ->standard($this->txt('close'), '') + $buttons[] = $this->getPromptCloseButton(); + + $tpl->setVariable('TITLE', $component->getTitle()); + $tpl->setVariable('BUTTONS', $default_renderer->render($buttons)); + return $tpl->get(); + } + + protected function getFormSubmitButton( + \ILIAS\UI\Component\Input\Container\Form\Form $form + ): \ILIAS\UI\Component\Button\Button { + return $this->getUIFactory()->button()->standard( + $form->getSubmitLabel() ?? $this->txt('save'), + $form->getSubmitSignal() + ); + } + + protected function getPromptCloseButton(?string $label = null): \ILIAS\UI\Component\Button\Button + { + return $this->getUIFactory()->button() + ->standard($label ?? $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('BUTTONS', $default_renderer->render($buttons)); - return $tpl->get(); } - } diff --git a/components/ILIAS/UI/src/Implementation/Render/FSLoader.php b/components/ILIAS/UI/src/Implementation/Render/FSLoader.php index 851679081989..33e86641ca20 100755 --- a/components/ILIAS/UI/src/Implementation/Render/FSLoader.php +++ b/components/ILIAS/UI/src/Implementation/Render/FSLoader.php @@ -29,6 +29,7 @@ use ILIAS\UI\Implementation\Component\Input\Container\Form\Form; use ILIAS\UI\Implementation\Component\Menu\Menu; use ILIAS\UI\Implementation\Component\Listing\Listing; +use ILIAS\UI\Component\Entity\Entity; /** * Loads renderers for components from the file system. @@ -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, ) { } @@ -77,6 +79,9 @@ public function getRendererFactoryFor(Component $component): RendererFactory if ($component instanceof MessageBox) { return $this->message_box_renderer_factory; } + if ($component instanceof Entity) { + return $this->entity_renderer_factory; + } if ($component instanceof Form) { return $this->form_renderer_factory; } 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/Entity/base.php b/components/ILIAS/UI/src/examples/Input/Field/Entity/base.php new file mode 100644 index 000000000000..3e831002e2ff --- /dev/null +++ b/components/ILIAS/UI/src/examples/Input/Field/Entity/base.php @@ -0,0 +1,85 @@ + + * Example of the Entity Input in a Standard Form with compact (brief) + * entities: only primary identifier, empty secondary identifier, no further + * sections. The field posts entity ids and is not operated by the user. + * + * expected output: > + * A form shows a compact list of entity titles. Submitting the form displays the posted ids. + * --- + */ +function base(): string +{ + global $DIC; + $factory = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + $request = $DIC->http()->request(); + + $form = $factory->input()->container()->form()->standard('#', [ + $factory->input()->field()->entity(new EntityInputExampleRetrieval()) + ->withValue([0, 1]), + ]); + + if ($request->getMethod() === 'POST') { + $form = $form->withRequest($request); + $data = $form->getData(); + + return $renderer->render($form) + . '
' . htmlspecialchars(print_r($data, true), ENT_QUOTES, 'UTF-8') . '
'; + } + + return $renderer->render($form); +} + +class EntityInputExampleRetrieval implements EntityRetrieval +{ + public function getEntities( + \ILIAS\UI\Factory $ui_factory, + Range $range, + Order $order, + mixed $additional_viewcontrol_data, + mixed $filter_data, + mixed $additional_parameters, + ): Generator { + yield from []; + } + + 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, 'Entity ' . $entity_id, ''); + } + } +} diff --git a/components/ILIAS/UI/src/examples/Input/Field/Entity/with_details.php b/components/ILIAS/UI/src/examples/Input/Field/Entity/with_details.php new file mode 100644 index 000000000000..a4e7260af66c --- /dev/null +++ b/components/ILIAS/UI/src/examples/Input/Field/Entity/with_details.php @@ -0,0 +1,115 @@ + + * Entity Input with fully populated Entity components (avatar, main details, + * details). Unlike the base example, these are not rendered in the compact + * brief layout. + * + * expected output: > + * A form shows a list of rich entity cards. Submitting the form displays the posted ids. + * --- + */ +function with_details(): string +{ + global $DIC; + $factory = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + $request = $DIC->http()->request(); + + $form = $factory->input()->container()->form()->standard('#', [ + $factory->input()->field()->entity(new EntityInputDetailedRetrieval()) + ->withValue([0, 1, 2]), + ]); + + if ($request->getMethod() === 'POST') { + $form = $form->withRequest($request); + $data = $form->getData(); + + return $renderer->render($form) + . '
' . htmlspecialchars(print_r($data, true), ENT_QUOTES, 'UTF-8') . '
'; + } + + return $renderer->render($form); +} + +class EntityInputDetailedRetrieval 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], + ]; + + public function getEntities( + \ILIAS\UI\Factory $ui_factory, + Range $range, + Order $order, + mixed $additional_viewcontrol_data, + mixed $filter_data, + mixed $additional_parameters, + ): Generator { + yield from []; + } + + 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])) { + throw new InvalidArgumentException('Unknown entity id: ' . $entity_id); + } + yield $this->mapRecord($ui_factory, $entity_id, $this->data[$entity_id]); + } + } + + 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 $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/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/confirmation.php b/components/ILIAS/UI/src/examples/Prompt/Standard/confirmation.php new file mode 100644 index 000000000000..616a84f171d6 --- /dev/null +++ b/components/ILIAS/UI/src/examples/Prompt/Standard/confirmation.php @@ -0,0 +1,220 @@ + + * A confirmation prompt for deleting or changing multiple entities. + * Consumers compose a Standard Form with field()->entity() (ids via withValue) + * and show it as the primary Prompt State content. + * A confirmation Message Box next to that Form depends on Tim Schmitz' + * follow-up to GitHub #11105 ("Secondary Prompt Content": + * show($primary, ...$secondary)). Until that lands, this example uses + * show($form) only; adding the Message Box is then a one-argument change. + * + * expected output: > + * A button opens a prompt listing selected entities. + * Submitting posts the entity ids via the Standard Form (POST body); the result is shown in the prompt. + * The prompt can be closed. + * --- + */ +function confirmation(): string +{ + global $DIC; + + $http = $DIC->http(); + $factory = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + $query = $http->wrapper()->query(); + $data_factory = new \ILIAS\Data\Factory(); + $refinery = $DIC['refinery']; + + $here_uri = $data_factory->uri((string) $http->request()->getUri()); + $url_builder = new URLBuilder($here_uri); + + $example_namespace = ['prompt', 'confirmation']; + $demo_entity_ids = [1, 2, 3]; + + // when expecting a state, we do not want to render other examples on the same page + [$url_builder, $endpoint_token] = $url_builder->acquireParameters($example_namespace, 'endpoint'); + $url_builder = $url_builder->withParameter($endpoint_token, 'true'); + + [$url_builder, $confirm_token] = $url_builder->acquireParameters($example_namespace, 'confirm'); + [$url_builder, $process_token] = $url_builder->acquireParameters($example_namespace, 'process'); + + // async GET: load confirmation prompt state + if ($query->has($confirm_token->getName())) { + $entity_ids = retrieveEntityIds($query, $confirm_token->getName(), $refinery); + if ($entity_ids !== null) { + $post_url = $url_builder->withParameter($process_token, '1'); + $form = $factory->input()->container()->form()->standard( + (string) $post_url->buildURI(), + [ + $factory->input()->field()->entity(new ConfirmationEntityRetrieval()) + ->withValue($entity_ids), + ] + )->withSubmitLabel('Confirm'); + + // Ready for secondary Prompt content (#11105 follow-up): + // $message = $factory->messageBox()->confirmation('Really perform this action?'); + // $state = $factory->prompt()->state()->show($form, $message)->withTitle(...); + $state = $factory->prompt()->state()->show($form)->withTitle('Performing some action'); + + echo $renderer->renderAsync($state); + exit; + } + } + + // async POST: form submit after confirmation + if ($http->request()->getMethod() === 'POST' && $query->has($process_token->getName())) { + $process = $query->retrieve($process_token->getName(), $refinery->kindlyTo()->string()); + if ($process !== '') { + $form = $factory->input()->container()->form()->standard( + (string) $url_builder->withParameter($process_token, '1')->buildURI(), + [ + $factory->input()->field()->entity(new ConfirmationEntityRetrieval()) + ->withValue($demo_entity_ids), + ] + ); + $data = $form->withRequest($http->request())->getData(); + $entity_ids = array_values((array) (is_array($data) ? array_values($data)[0] ?? [] : [])); + $message = $factory->messageBox()->success( + 'Submitted entity ids: ' . implode(', ', array_map('strval', $entity_ids)) + ); + echo $renderer->renderAsync( + $factory->prompt()->state()->show($message)->withTitle('Confirmation result') + ); + exit; + } + } + + $open_uri = $url_builder + ->withParameter($confirm_token, $demo_entity_ids) + ->buildURI(); + + $prompt = $factory->prompt()->standard($open_uri); + $trigger = $factory->button()->primary('Open confirmation prompt', $prompt->getShowSignal($open_uri)); + + if (!$query->has($endpoint_token->getName())) { + return $renderer->render([$prompt, $trigger]); + } + + return ''; +} + +/** + * @return array|null + */ +function retrieveEntityIds( + \ILIAS\HTTP\Wrapper\RequestWrapper $wrapper, + string $parameter_name, + \ILIAS\Refinery\Factory $refinery, +): ?array { + if (!$wrapper->has($parameter_name)) { + return null; + } + + $raw = $wrapper->retrieve( + $parameter_name, + $refinery->custom()->transformation(static fn(mixed $value): mixed => $value) + ); + + if (!is_array($raw)) { + $raw = ($raw === '' || $raw === null) ? [] : [$raw]; + } + + $raw = array_values(array_filter( + $raw, + static fn(mixed $value): bool => $value !== '' && $value !== null + )); + + if ($raw === []) { + return null; + } + + return $refinery->kindlyTo()->listOf($refinery->kindlyTo()->int())->transform($raw); +} + +class ConfirmationEntityRetrieval 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( + \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 { + // Must yield one entity per id in the same order (Entity Input renderer lockstep). + foreach ($entity_ids as $entity_id) { + if (!isset($this->data[$entity_id])) { + throw new InvalidArgumentException('Unknown entity id: ' . $entity_id); + } + yield $this->mapRecord($ui_factory, $entity_id, $this->data[$entity_id]); + } + } + + 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 $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/confirmation_type_title.php b/components/ILIAS/UI/src/examples/Prompt/Standard/confirmation_type_title.php new file mode 100644 index 000000000000..bef7fa5c7a3c --- /dev/null +++ b/components/ILIAS/UI/src/examples/Prompt/Standard/confirmation_type_title.php @@ -0,0 +1,112 @@ + + * A confirmation prompt for deleting a single object without Entity Input. + * The user must re-type the object title in a Text Input to confirm. + * A confirmation Message Box as sibling Prompt content can be added once + * secondary Prompt content is available (follow-up to GitHub issue 11105). + * + * expected output: > + * A button opens a prompt with a form asking to type the object title. + * Submitting with a wrong or empty title keeps the prompt open and shows + * an error on the text input. + * Typing the exact title and submitting closes the prompt. + * --- + */ +function confirmation_type_title(): string +{ + global $DIC; + + $http = $DIC->http(); + $factory = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + $query = $http->wrapper()->query(); + $data_factory = new \ILIAS\Data\Factory(); + $refinery = $DIC['refinery']; + + $object_title = 'Introduction to ILIAS'; + + $here_uri = $data_factory->uri((string) $http->request()->getUri()); + $url_builder = new URLBuilder($here_uri); + + $example_namespace = ['prompt', 'confirmation_type_title']; + [$url_builder, $endpoint_token] = $url_builder->acquireParameters($example_namespace, 'endpoint'); + $url_builder = $url_builder->withParameter($endpoint_token, 'true'); + + [$url_builder, $action_token] = $url_builder->acquireParameters($example_namespace, 'action'); + $form_uri = $url_builder->withParameter($action_token, 'form')->buildURI(); + + $build_form = static function (string $post_url) use ($factory, $refinery, $object_title) { + $title_field = $factory->input()->field()->text( + 'Object title', + sprintf('Type "%s" to confirm deletion.', $object_title) + )->withRequired(true) + ->withAdditionalTransformation( + $refinery->custom()->constraint( + static fn(mixed $value): bool => is_string($value) && $value === $object_title, + sprintf('The title must match "%s" exactly.', $object_title) + ) + ); + + return $factory->input()->container()->form()->standard( + $post_url, + [$title_field] + )->withSubmitLabel('Delete'); + }; + + if ($query->has($action_token->getName()) && + $query->retrieve($action_token->getName(), $refinery->kindlyTo()->string()) === 'form' + ) { + $form = $build_form((string) $form_uri); + $title = sprintf('Delete "%s"?', $object_title); + + if ($http->request()->getMethod() === 'POST') { + $form = $form->withRequest($http->request()); + if ($form->getData() !== null) { + echo $renderer->renderAsync($factory->prompt()->state()->close()); + exit; + } + } + + echo $renderer->renderAsync( + $factory->prompt()->state()->show($form)->withTitle($title) + ); + exit; + } + + $prompt = $factory->prompt()->standard($form_uri); + $trigger = $factory->button()->primary( + 'Open delete confirmation (type title)', + $prompt->getShowSignal($form_uri) + ); + + if (!$query->has($endpoint_token->getName())) { + return $renderer->render([$prompt, $trigger]); + } + + return ''; +} diff --git a/components/ILIAS/UI/src/templates/default/Entity/tpl.entity-brief.html b/components/ILIAS/UI/src/templates/default/Entity/tpl.entity-brief.html new file mode 100644 index 000000000000..cea5d6123b0a --- /dev/null +++ b/components/ILIAS/UI/src/templates/default/Entity/tpl.entity-brief.html @@ -0,0 +1,3 @@ +
+ {CONTENT} +
diff --git a/components/ILIAS/UI/src/templates/default/Input/tpl.entity.html b/components/ILIAS/UI/src/templates/default/Input/tpl.entity.html new file mode 100644 index 000000000000..0f37665b5f9e --- /dev/null +++ b/components/ILIAS/UI/src/templates/default/Input/tpl.entity.html @@ -0,0 +1,8 @@ +
    + +
  • + {ENTITY} + {INPUT} +
  • + +
diff --git a/components/ILIAS/UI/tests/Base.php b/components/ILIAS/UI/tests/Base.php index 0412bff2ac42..5bf1f5466c2b 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 ILIAS\UI\Implementation\Component\Entity\EntityRendererFactory; class ilIndependentTemplateFactory implements TemplateFactory { @@ -480,6 +481,16 @@ public function getDefaultRenderer( $help_text_retriever, $this->getUploadLimitResolver(), ), + new EntityRendererFactory( + $ui_factory, + $tpl_factory, + $lng, + $js_binding, + $image_path_resolver, + $data_factory, + $help_text_retriever, + $this->getUploadLimitResolver(), + ), new I\Listing\ListingRendererFactory( $ui_factory, $tpl_factory, 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/Field/EntityInputTest.php b/components/ILIAS/UI/tests/Component/Input/Field/EntityInputTest.php new file mode 100644 index 000000000000..bb69f7bb1ed2 --- /dev/null +++ b/components/ILIAS/UI/tests/Component/Input/Field/EntityInputTest.php @@ -0,0 +1,99 @@ +getEntityRetrieval(); + $field = $this->getFieldFactory()->entity($retrieval)->withValue([11, 22]); + + $this->assertInstanceOf(C\Input\Field\Entity::class, $field); + $this->assertCount(2, $field->getGeneratedDynamicInputs()); + $this->assertSame([11, 22], array_values($field->getValue())); + $this->assertSame($retrieval, $field->getEntityRetrieval()); + } + + public function testEntityWithValueRejectsNonArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->getFieldFactory()->entity($this->getEntityRetrieval())->withValue('11'); + } + + public function testEntityFormGetDataReadsIdsFromRequest(): void + { + $field_factory = $this->getFieldFactory(); + $form_factory = new I\Input\Container\Form\Factory($field_factory, new I\SignalGenerator()); + $form = $form_factory->standard('https://example.com/go', [ + $field_factory->entity($this->getEntityRetrieval())->withValue([11, 22]), + ]); + + $entity_input = array_values($form->getInputs())[0]; + $this->assertInstanceOf(C\Input\Field\Entity::class, $entity_input); + + $parent_name = $entity_input->getName(); + $this->assertNotNull($parent_name); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn([ + $parent_name => [ + 'input_0' => ['11', '22'], + ], + ]); + + $data = $form->withRequest($request)->getData(); + $this->assertIsArray($data); + $this->assertSame(['11', '22'], array_values((array) array_values($data)[0])); + } + + private function getEntityRetrieval(): C\Entity\EntityRetrieval + { + return new class () implements C\Entity\EntityRetrieval { + public function getEntities( + ILIAS\UI\Factory $ui_factory, + ILIAS\Data\Range $range, + ILIAS\Data\Order $order, + mixed $additional_viewcontrol_data, + mixed $filter_data, + mixed $additional_parameters, + ): Generator { + yield from []; + } + + public function getEntitiesByIds( + ILIAS\UI\Factory $ui_factory, + ILIAS\Data\Order $order, + array $entity_ids, + ): Generator { + yield from []; + } + }; + } +} diff --git a/components/ILIAS/UI/tests/Component/Listing/Entity/EntityListingTest.php b/components/ILIAS/UI/tests/Component/Listing/Entity/EntityListingTest.php index 479f902e0d17..8bbc42ab3cba 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(); @@ -59,34 +77,18 @@ public function testEntityListingFactory(): void { $this->assertInstanceOf( I\Listing\Entity\EntityListing::class, - $this->getUIFactory()->listing()->entity()->standard($this->getEntityMapping()) + $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..ac217842b461 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(); } @@ -67,29 +85,14 @@ public function testGridEntityListingFactory(): void { $this->assertInstanceOf( C\Listing\Entity\EntityListing::class, - $this->getUIFactory()->listing()->entity()->grid($this->getEntityMapping()) + $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 = <<data as $entry) { - yield $mapping->map($entry); - } - } - }; - $listing = $this->getUIFactory()->listing()->entity() - ->grid($this->getEntityMapping()) - ->withData($data); + ->grid($this->getEntityRetrieval()); $entities = iterator_to_array($listing->getEntities($this->getUIFactory())); diff --git a/components/ILIAS/UI/tests/InitUIFramework.php b/components/ILIAS/UI/tests/InitUIFramework.php index b5425805726e..6d8611c301bb 100755 --- a/components/ILIAS/UI/tests/InitUIFramework.php +++ b/components/ILIAS/UI/tests/InitUIFramework.php @@ -381,6 +381,16 @@ public function getRefreshIntervalInMs(): int $c["help.text_retriever"], $c["ui.upload_limit_resolver"] ), + new ILIAS\UI\Implementation\Component\Entity\EntityRendererFactory( + $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.factory"], $c["ui.template_factory"], @@ -429,8 +439,15 @@ public function getRefreshIntervalInMs(): int return new ILIAS\UI\Implementation\Component\Entity\Factory(); }; + $c["ui.factory.prompt.state"] = function ($c) { + return new ILIAS\UI\Implementation\Component\Prompt\State\Factory(); + }; + $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.navigation"] = function ($c) { diff --git a/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php b/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php index 634360242a54..62902ec1a023 100755 --- a/components/ILIAS/UI/tests/Renderer/FSLoaderTest.php +++ b/components/ILIAS/UI/tests/Renderer/FSLoaderTest.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\Component\Entity\Entity; use ILIAS\UI\Component\Component; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -41,6 +42,7 @@ class FSLoaderTest extends TestCase protected RendererFactory & MockObject $message_box_renderer_factory; protected RendererFactory & MockObject $form_renderer_factory; protected RendererFactory & MockObject $menu_renderer_factory; + protected RendererFactory & MockObject $entity_renderer_factory; protected RendererFactory & MockObject $list_renderer_factory; protected FSLoader $fs_loader; @@ -53,6 +55,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->createMock(RendererFactory::class); $this->list_renderer_factory = $this->createMock(RendererFactory::class); $this->fs_loader = new FSLoader( @@ -62,6 +65,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 +146,13 @@ public function testGetRendererFactoryForButton(): void $this->assertSame($factory, $this->button_renderer_factory); } + public function testGetRendererFactoryForEntity(): void + { + $component_mock = $this->createMock(Entity::class); + $factory = $this->fs_loader->getRendererFactoryFor($component_mock); + $this->assertSame($factory, $this->entity_renderer_factory); + } + public function testGetRendererFactoryForOther(): void { $component_mock = $this->createMock(Component::class); diff --git a/templates/default/070-components/UI-framework/Input/_ui-component_entity.scss b/templates/default/070-components/UI-framework/Input/_ui-component_entity.scss new file mode 100644 index 000000000000..519c1ba0f22f --- /dev/null +++ b/templates/default/070-components/UI-framework/Input/_ui-component_entity.scss @@ -0,0 +1,37 @@ +@use "../../../050-layout/basics/" as l; + +// Entity Input is not user-operated; render full-width without the form label column. +.c-form .c-input[data-il-ui-component="entity-field-input"].c-input { + display: flex; + flex-direction: column; + + > label:empty { + display: none; + } + + > label:not(:empty) { + width: 100%; + padding-right: 0; + margin-bottom: l.$il-margin-base-vertical; + } + + > .c-input__help-byline, + > .c-input__error-msg, + > .c-input__field { + width: 100%; + } +} + +.c-form ul.c-input-entity { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: l.$il-margin-large-vertical; + + > li { + margin: 0; + padding: 0; + } +} diff --git a/templates/default/070-components/UI-framework/Input/_ui-component_input.scss b/templates/default/070-components/UI-framework/Input/_ui-component_input.scss index 3cfe33a42477..b6889311bb30 100755 --- a/templates/default/070-components/UI-framework/Input/_ui-component_input.scss +++ b/templates/default/070-components/UI-framework/Input/_ui-component_input.scss @@ -29,6 +29,7 @@ @use "_ui-component_password.scss"; @use "_ui-component_radio.scss"; @use "_ui-component_multiselect.scss"; +@use "_ui-component_entity.scss"; @use "_ui-component_textarea.scss"; @use "_ui-component_filter.scss"; @use "_ui-component_duration.scss"; @@ -115,7 +116,7 @@ /// styling shared by multiple input elements // hover/focus extends beyond box without pushing it -.c-input:not([data-il-ui-component="section-field-input"]) { +.c-input:not([data-il-ui-component="section-field-input"]):not([data-il-ui-component="entity-field-input"]) { &:hover { box-shadow: 0px 0px 0px l.$il-margin-base-horizontal $il-main-dark-bg; background-color: $il-main-dark-bg; @@ -146,7 +147,7 @@ } margin-bottom: 0; // overrides alert default } - .c-input[data-il-ui-component]:not([data-il-ui-component="section-field-input"]):has(> .c-input__error-msg) { + .c-input[data-il-ui-component]:not([data-il-ui-component="section-field-input"]):not([data-il-ui-component="entity-field-input"]):has(> .c-input__error-msg) { border-inline-start: 6px solid $il-alert-danger-border; padding-left: l.$il-padding-base-horizontal; } diff --git a/templates/default/delos.css b/templates/default/delos.css index 3db6ac03ec63..9ae1000e01c9 100644 --- a/templates/default/delos.css +++ b/templates/default/delos.css @@ -8571,6 +8571,36 @@ button .minimize, button .close { width: 100%; } +.c-form .c-input[data-il-ui-component=entity-field-input].c-input { + display: flex; + flex-direction: column; +} +.c-form .c-input[data-il-ui-component=entity-field-input].c-input > label:empty { + display: none; +} +.c-form .c-input[data-il-ui-component=entity-field-input].c-input > label:not(:empty) { + width: 100%; + padding-right: 0; + margin-bottom: 3px; +} +.c-form .c-input[data-il-ui-component=entity-field-input].c-input > .c-input__help-byline, +.c-form .c-input[data-il-ui-component=entity-field-input].c-input > .c-input__error-msg, +.c-form .c-input[data-il-ui-component=entity-field-input].c-input > .c-input__field { + width: 100%; +} +.c-form ul.c-input-entity { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; +} +.c-form ul.c-input-entity > li { + margin: 0; + padding: 0; +} + .c-form ul.c-field-multiselect { list-style: none; padding: 0; @@ -9765,11 +9795,11 @@ ul.c-input--has-mustache-variables__definitions > li { } } -.c-input:not([data-il-ui-component=section-field-input]):hover { +.c-input:not([data-il-ui-component=section-field-input]):not([data-il-ui-component=entity-field-input]):hover { box-shadow: 0px 0px 0px 9px #f9f9f9; background-color: #f9f9f9; } -.c-input:not([data-il-ui-component=section-field-input]):has(:focus-visible) { +.c-input:not([data-il-ui-component=section-field-input]):not([data-il-ui-component=entity-field-input]):has(:focus-visible) { box-shadow: 0px 0px 0px 9px rgb(226.2857142857, 231.6428571429, 238.7142857143); background-color: rgb(226.2857142857, 231.6428571429, 238.7142857143); } @@ -9795,7 +9825,7 @@ ul.c-input--has-mustache-variables__definitions > li { .c-form .c-input__error-msg { margin-bottom: 0; } -.c-form .c-input[data-il-ui-component]:not([data-il-ui-component=section-field-input]):has(> .c-input__error-msg) { +.c-form .c-input[data-il-ui-component]:not([data-il-ui-component=section-field-input]):not([data-il-ui-component=entity-field-input]):has(> .c-input__error-msg) { border-inline-start: 6px solid rgb(255, 214.54, 214.54); padding-left: 9px; }