- 
                Notifications
    
You must be signed in to change notification settings  - Fork 32
 
✨ nicegui rendering aids #8427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
          
     Merged
      
      
            GitHK
  merged 35 commits into
  ITISFoundation:master
from
GitHK:pr-osparc-migrate-dy-scheduler-part4
  
      
      
   
  Sep 29, 2025 
      
    
  
     Merged
                    ✨ nicegui rendering aids #8427
Changes from 33 commits
      Commits
    
    
            Show all changes
          
          
            35 commits
          
        
        Select commit
          Hold shift + click to select a range
      
      69d528b
              
                fixed directory structure
              
              
                 92880f1
              
                Merge remote-tracking branch 'upstream/master' into pr-osparc-migrate…
              
              
                 060f553
              
                added base display model
              
              
                 9953126
              
                fixed interface
              
              
                 ccfc0e1
              
                refactor
              
              
                 8029182
              
                fixed types
              
              
                 3528daf
              
                enhanced testing utils
              
              
                 3b8056b
              
                refactor subscribers
              
              
                 27ffd9c
              
                fixed test
              
              
                 2f29be2
              
                baseline test
              
              
                 488661f
              
                refactor
              
              
                 5398166
              
                revert change
              
              
                 8e94831
              
                refactor
              
              
                 11fbf6a
              
                fixed test
              
              
                 2f05c0c
              
                speedup tests
              
              
                 a5beb8a
              
                fixed tests
              
              
                 8607c2e
              
                Merge remote-tracking branch 'upstream/master' into pr-osparc-migrate…
              
              
                 b7fca98
              
                added signal to remove an UI element form the model
              
              
                 24da1d0
              
                Merge remote-tracking branch 'upstream/master' into pr-osparc-migrate…
              
              
                 d86d292
              
                fixed tests
              
              
                 ed3bec1
              
                refactor test
              
              
                 5cb54ff
              
                refactor
              
              
                 74874ef
              
                renamed
              
              
                 64aa3af
              
                made tests more redable
              
              
                 751a3b1
              
                added basic rendering logic
              
              
                 77ca02e
              
                enhanced helpers
              
              
                 e88a8d8
              
                refactor
              
              
                 27466ab
              
                rename
              
              
                 02eaab2
              
                docstring
              
              
                 62c4deb
              
                refactor
              
              
                 832c17b
              
                Merge remote-tracking branch 'upstream/master' into pr-osparc-migrate…
              
              
                 8e5b3d6
              
                typo
              
              
                 48e84c1
              
                typo
              
              
                 741e0f3
              
                added note
              
              
                 c45096b
              
                feedback
              
              
                 File filter
Filter by extension
Conversations
          Failed to load comments.   
        
        
          
      Loading
        
  Jump to
        
          Jump to file
        
      
      
          Failed to load files.   
        
        
          
      Loading
        
  Diff view
Diff view
There are no files selected for viewing
              Empty file.
          
    
        
          
          
            32 changes: 32 additions & 0 deletions
          
          32 
        
  ...s/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/_mixins.py
  
  
      
      
   
        
      
      
    
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| from abc import ABC, abstractmethod | ||
| from functools import cached_property | ||
| 
     | 
||
| from nicegui import ui | ||
| from nicegui.element import Element | ||
| 
     | 
||
| 
     | 
||
| class ParentMixin: | ||
| def __init__(self) -> None: | ||
| self._parent: Element | None = None | ||
| 
     | 
||
| @staticmethod | ||
| def _get_parent() -> Element: | ||
| """overwrite to use a different parent element""" | ||
| return ui.element() | ||
| 
     | 
||
| @cached_property | ||
| def parent(self) -> Element: | ||
| if self._parent is None: | ||
| self._parent = self._get_parent() | ||
                
      
                  GitHK marked this conversation as resolved.
               
          
            Show resolved
            Hide resolved
         | 
||
| return self._parent | ||
| 
     | 
||
| def remove_parent(self) -> None: | ||
| if self._parent is not None: | ||
| self._parent.delete() | ||
| self._parent = None | ||
| 
     | 
||
| 
     | 
||
| class DisplayaMixin(ABC): | ||
| @abstractmethod | ||
| def display(self) -> None: | ||
| """creates ui elements inside the parent container""" | ||
        
          
          
            23 changes: 23 additions & 0 deletions
          
          23 
        
  ...ic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_component.py
  
  
      
      
   
        
      
      
    
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| from abc import abstractmethod | ||
| from typing import Generic, TypeVar | ||
| 
     | 
||
| from ._mixins import DisplayaMixin, ParentMixin | ||
| from .base_display_model import BaseUpdatableDisplayModel | ||
| 
     | 
||
| M = TypeVar("M", bound=BaseUpdatableDisplayModel) | ||
| 
     | 
||
| 
     | 
||
| class BaseUpdatableComponent(DisplayaMixin, ParentMixin, Generic[M]): | ||
| def __init__(self, display_model: M): | ||
| super().__init__() | ||
| 
     | 
||
| self.display_model = display_model | ||
                
      
                  GitHK marked this conversation as resolved.
               
          
            Show resolved
            Hide resolved
         | 
||
| self.display_model.on_remove_from_ui(self.remove_parent) | ||
| 
     | 
||
| def display(self) -> None: | ||
| with self.parent: | ||
| self._draw_ui() | ||
| 
     | 
||
| @abstractmethod | ||
| def _draw_ui(self) -> None: | ||
| """creates ui elements inside the parent container""" | ||
        
          
          
            81 changes: 81 additions & 0 deletions
          
          81 
        
  ...cheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_display_model.py
  
  
      
      
   
        
      
      
    
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| from collections.abc import Callable | ||
| from typing import Any, Self, TypeAlias | ||
| 
     | 
||
| from pydantic import BaseModel, NonNegativeInt, PrivateAttr | ||
| 
     | 
||
| CompleteModelDict: TypeAlias = dict[str, Any] | ||
| 
     | 
||
| 
     | 
||
| class BaseUpdatableDisplayModel(BaseModel): | ||
| _on_type_change_subscribers: dict[str, Callable] = PrivateAttr(default_factory=dict) | ||
                
      
                  GitHK marked this conversation as resolved.
               
              
                Outdated
          
            Show resolved
            Hide resolved
         | 
||
| _on_value_change_subscribers: dict[str, Callable] = PrivateAttr( | ||
| default_factory=dict | ||
| ) | ||
| _on_remove_from_ui_callback: Callable | None = PrivateAttr(default=None) | ||
| 
     | 
||
| def _get_on_change_callbacks_to_run(self, update_obj: Self) -> list[Callable]: | ||
| callbacks_to_run: list[Callable] = [] | ||
| 
     | 
||
| for attribute_name, callback in self._on_value_change_subscribers.items(): | ||
| if getattr(self, attribute_name) != getattr(update_obj, attribute_name): | ||
| callbacks_to_run.append(callback) | ||
| 
     | 
||
| for attribute_name, callback in self._on_type_change_subscribers.items(): | ||
| if type(getattr(self, attribute_name)) is not type( | ||
| getattr(update_obj, attribute_name) | ||
| ): | ||
                
      
                  GitHK marked this conversation as resolved.
               
          
            Show resolved
            Hide resolved
         | 
||
| callbacks_to_run.append(callback) | ||
| 
     | 
||
| return callbacks_to_run | ||
| 
     | 
||
| def update(self, update_obj: Self) -> NonNegativeInt: | ||
| """ | ||
| updates the model with the values from update_obj | ||
| returns the number of callbacks that were run | ||
| """ | ||
| callbacks_to_run = self._get_on_change_callbacks_to_run(update_obj) | ||
| 
     | 
||
| for attribute_name, update_value in update_obj.__dict__.items(): | ||
| current_value = getattr(self, attribute_name) | ||
| if current_value != update_value: | ||
| if isinstance(update_value, BaseUpdatableDisplayModel): | ||
| if type(current_value) is type(update_value): | ||
| current_value.update(update_value) | ||
| else: | ||
| setattr(self, attribute_name, update_value) | ||
| else: | ||
| setattr(self, attribute_name, update_value) | ||
| 
     | 
||
| for callback in callbacks_to_run: | ||
| callback() | ||
| 
     | 
||
| return len(callbacks_to_run) | ||
| 
     | 
||
                
      
                  GitHK marked this conversation as resolved.
               
          
            Show resolved
            Hide resolved
         | 
||
| def remove_from_ui(self) -> None: | ||
| """the UI will remove the component associated with this model""" | ||
| if self._on_remove_from_ui_callback: | ||
| self._on_remove_from_ui_callback() | ||
| 
     | 
||
| def _raise_if_attribute_not_declared_in_model(self, attribute: str) -> None: | ||
| if attribute not in self.__class__.model_fields: | ||
| msg = f"Attribute '{attribute}' is not part of the model fields" | ||
| raise ValueError(msg) | ||
| 
     | 
||
| def on_type_change(self, attribute: str, callback: Callable) -> None: | ||
| """subscribe callback to an attribute TYPE change""" | ||
| self._raise_if_attribute_not_declared_in_model(attribute) | ||
| 
     | 
||
| self._on_type_change_subscribers[attribute] = callback | ||
| 
     | 
||
| def on_value_change(self, attribute: str, callback: Callable) -> None: | ||
| """subscribe callback to an attribute VALUE change""" | ||
| self._raise_if_attribute_not_declared_in_model(attribute) | ||
| 
     | 
||
| self._on_value_change_subscribers[attribute] = callback | ||
| 
     | 
||
| def on_remove_from_ui(self, callback: Callable) -> None: | ||
| """ | ||
| invokes callback when object is no longer required, | ||
| allows the UI to have a clear hook to remove the component | ||
| """ | ||
| self._on_remove_from_ui_callback = callback | ||
        
          
          
            65 changes: 65 additions & 0 deletions
          
          65 
        
  ...ces/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/stack.py
  
  
      
      
   
        
      
      
    
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| from typing import Generic, TypeAlias, TypeVar | ||
| 
     | 
||
| from ._mixins import DisplayaMixin, ParentMixin | ||
| from .base_component import BaseUpdatableComponent | ||
| from .base_display_model import BaseUpdatableDisplayModel | ||
| 
     | 
||
| M = TypeVar("M", bound=BaseUpdatableDisplayModel) | ||
| 
     | 
||
| Reference: TypeAlias = str | ||
| 
     | 
||
| 
     | 
||
| class UpdatableComponentStack(DisplayaMixin, ParentMixin, Generic[M]): | ||
| """ | ||
| Renders `BaseUpdatableComponent` models via the provided `BaseUpdatableDisplayModel` | ||
| Appends new elements to the parent container. | ||
| """ | ||
| 
     | 
||
| def __init__(self, component: type[BaseUpdatableComponent]) -> None: | ||
| super().__init__() | ||
| self.component = component | ||
| 
     | 
||
| self._added_models: dict[Reference, M] = {} | ||
| self._rendered_models: set[Reference] = set() | ||
| 
     | 
||
| def _render_to_parent(self) -> None: | ||
| with self.parent: | ||
| for reference in self._added_models: | ||
| if reference not in self._rendered_models: | ||
| self._render_component(reference) | ||
| 
     | 
||
| def _render_component(self, reference: Reference) -> None: | ||
| with self.parent: | ||
| model = self._added_models[reference] | ||
| component = self.component(model) | ||
| component.display() | ||
| self._rendered_models.add(reference) | ||
| 
     | 
||
| def display(self) -> None: | ||
| self._render_to_parent() | ||
| 
     | 
||
| def add_or_update_model(self, reference: Reference, display_model: M) -> None: | ||
| """adds or updates and existing ui element form a given model""" | ||
| if reference not in self._added_models: | ||
| self._added_models[reference] = display_model | ||
| self._render_component(reference) | ||
| else: | ||
| self._added_models[reference].update(display_model) | ||
| 
     | 
||
| def remove_model(self, reference: Reference) -> None: | ||
| """removes a model from the ui via it's given reference""" | ||
| if reference in self._added_models: | ||
| self._added_models[reference].remove_from_ui() | ||
| del self._added_models[reference] | ||
| self._rendered_models.remove(reference) | ||
| 
     | 
||
| def update_from_dict(self, models: dict[Reference, M]) -> None: | ||
| """updates UI given a new input""" | ||
| # remove models that are not in the new list | ||
| for reference in tuple(self._added_models.keys()): | ||
| if reference not in models: | ||
| self.remove_model(reference) | ||
| 
     | 
||
| # add or update existing models | ||
| for reference, model in models.items(): | ||
| self.add_or_update_model(reference, model) | 
        
          
          
            178 changes: 178 additions & 0 deletions
          
          178 
        
  services/dynamic-scheduler/tests/unit/api_frontend/_common/test_base_display_model.py
  
  
      
      
   
        
      
      
    
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| from unittest.mock import Mock | ||
| 
     | 
||
| import pytest | ||
| from pydantic import NonNegativeInt, TypeAdapter | ||
| from simcore_service_dynamic_scheduler.api.frontend._common.base_display_model import ( | ||
| BaseUpdatableDisplayModel, | ||
| CompleteModelDict, | ||
| ) | ||
| 
     | 
||
| 
     | 
||
| class Pet(BaseUpdatableDisplayModel): | ||
| name: str | ||
| species: str | ||
| 
     | 
||
| 
     | 
||
| class Friend(BaseUpdatableDisplayModel): | ||
| name: str | ||
| age: int | ||
| 
     | 
||
| 
     | 
||
| class RenderOnPropertyValueChange(BaseUpdatableDisplayModel): | ||
| name: str | ||
| age: int | ||
| companion: Pet | Friend | ||
| 
     | 
||
| 
     | 
||
| class RenderOnPropertyTypeChange(BaseUpdatableDisplayModel): | ||
| name: str | ||
| age: int | ||
| companion: Pet | Friend | ||
| 
     | 
||
| 
     | 
||
| @pytest.mark.parametrize( | ||
| "class_, initial_dict, update_dict, expected_dict, on_type_change, on_value_change", | ||
| [ | ||
| pytest.param( | ||
| Pet, | ||
| {"name": "Fluffy", "species": "cat"}, | ||
| {"name": "Fido", "species": "dog"}, | ||
| {"name": "Fido", "species": "dog"}, | ||
| {}, | ||
| {}, | ||
| id="does-not-require-rerender-without-any-render-on-declared", | ||
| ), | ||
| pytest.param( | ||
| RenderOnPropertyValueChange, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fluffy", "species": "cat"}, | ||
| }, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fido", "species": "dog"}, | ||
| }, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fido", "species": "dog"}, | ||
| }, | ||
| {}, | ||
| {"companion": 1}, | ||
| id="requires-rerender-on-property-change", | ||
| ), | ||
| pytest.param( | ||
| RenderOnPropertyValueChange, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fluffy", "species": "cat"}, | ||
| }, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fluffy", "species": "cat"}, | ||
| }, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fluffy", "species": "cat"}, | ||
| }, | ||
| {}, | ||
| {"companion": 0}, | ||
| id="do-not-require-rerender-if-same-value", | ||
| ), | ||
| pytest.param( | ||
| RenderOnPropertyTypeChange, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fluffy", "species": "cat"}, | ||
| }, | ||
| { | ||
| "name": "Alice", | ||
| "age": 31, | ||
| "companion": {"name": "Fido", "species": "dog"}, | ||
| }, | ||
| { | ||
| "name": "Alice", | ||
| "age": 31, | ||
| "companion": {"name": "Fido", "species": "dog"}, | ||
| }, | ||
| {"companion": 0}, | ||
| {}, | ||
| id="does-not-require-rerender-if-same-type-with-value-changes", | ||
| ), | ||
| pytest.param( | ||
| RenderOnPropertyTypeChange, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fluffy", "species": "cat"}, | ||
| }, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fluffy", "species": "cat"}, | ||
| }, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fluffy", "species": "cat"}, | ||
| }, | ||
| {"companion": 0}, | ||
| {}, | ||
| id="does-not-require-rerender-if-same-type-with-NO-value-changes", | ||
| ), | ||
| pytest.param( | ||
| RenderOnPropertyTypeChange, | ||
| { | ||
| "name": "Alice", | ||
| "age": 30, | ||
| "companion": {"name": "Fluffy", "species": "cat"}, | ||
| }, | ||
| {"name": "Alice", "age": 31, "companion": {"name": "Charlie", "age": 25}}, | ||
| {"name": "Alice", "age": 31, "companion": {"name": "Charlie", "age": 25}}, | ||
| {"companion": 1}, | ||
| {}, | ||
| id="requires-rerender-when-type-changes", | ||
| ), | ||
| ], | ||
| ) | ||
| def test_base_updatable_display_model( | ||
| class_: type[BaseUpdatableDisplayModel], | ||
| initial_dict: CompleteModelDict, | ||
| update_dict: CompleteModelDict, | ||
| expected_dict: CompleteModelDict, | ||
| on_type_change: dict[str, NonNegativeInt], | ||
| on_value_change: dict[str, NonNegativeInt], | ||
| ): | ||
| person = TypeAdapter(class_).validate_python(initial_dict) | ||
| assert person.model_dump() == initial_dict | ||
| 
     | 
||
| subscribed_on_type_changed: dict[str, Mock] = {} | ||
| for attribute in on_type_change: | ||
| mock = Mock() | ||
| person.on_type_change(attribute, mock) | ||
| subscribed_on_type_changed[attribute] = mock | ||
| 
     | 
||
| subscribed_on_value_change: dict[str, Mock] = {} | ||
| for attribute in on_value_change: | ||
| mock = Mock() | ||
| person.on_value_change(attribute, mock) | ||
| subscribed_on_value_change[attribute] = mock | ||
| 
     | 
||
| person.update(TypeAdapter(class_).validate_python(update_dict)) | ||
| assert person.model_dump() == expected_dict | ||
| 
     | 
||
| for attribute, mock in subscribed_on_type_changed.items(): | ||
| assert ( | ||
| mock.call_count == on_type_change[attribute] | ||
| ), f"wrong on_type_change count for '{attribute}'" | ||
| 
     | 
||
| for attribute, mock in subscribed_on_value_change.items(): | ||
| assert ( | ||
| mock.call_count == on_value_change[attribute] | ||
| ), f"wrong on_value_change count for '{attribute}'" | 
      
      Oops, something went wrong.
        
    
  
      
      Oops, something went wrong.
        
    
  
  Add this suggestion to a batch that can be applied as a single commit.
  This suggestion is invalid because no changes were made to the code.
  Suggestions cannot be applied while the pull request is closed.
  Suggestions cannot be applied while viewing a subset of changes.
  Only one suggestion per line can be applied in a batch.
  Add this suggestion to a batch that can be applied as a single commit.
  Applying suggestions on deleted lines is not supported.
  You must change the existing code in this line in order to create a valid suggestion.
  Outdated suggestions cannot be applied.
  This suggestion has been applied or marked resolved.
  Suggestions cannot be applied from pending reviews.
  Suggestions cannot be applied on multi-line comments.
  Suggestions cannot be applied while the pull request is queued to merge.
  Suggestion cannot be applied right now. Please check back later.
  
    
  
    
Uh oh!
There was an error while loading. Please reload this page.