Skip to content

Commit b7fca98

Browse files
author
Andrei Neagu
committed
added signal to remove an UI element form the model
1 parent 8607c2e commit b7fca98

File tree

3 files changed

+88
-12
lines changed

3 files changed

+88
-12
lines changed

services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_display_model.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class BaseUpdatableDisplayModel(BaseModel):
1111
_on_value_change_subscribers: dict[str, Callable] = PrivateAttr(
1212
default_factory=dict
1313
)
14+
_on_remove_from_ui_callback: Callable | None = PrivateAttr(default=None)
1415

1516
def _get_on_change_callbacks_to_run(self, update_obj: Self) -> list[Callable]:
1617
callbaks_to_run: list[Callable] = []
@@ -39,18 +40,22 @@ def update(self, update_obj: Self) -> NonNegativeInt:
3940
if current_value != update_value:
4041
if isinstance(update_value, BaseUpdatableDisplayModel):
4142
if type(current_value) is type(update_value):
42-
# when the same type update the existing object
4343
current_value.update(update_value)
4444
else:
4545
setattr(self, attribute_name, update_value)
46-
47-
setattr(self, attribute_name, update_value)
46+
else:
47+
setattr(self, attribute_name, update_value)
4848

4949
for callback in callbacks_to_run:
5050
callback()
5151

5252
return len(callbacks_to_run)
5353

54+
def remove_from_ui(self) -> None:
55+
"""the UI will remove the component associated with this model"""
56+
if self._on_remove_from_ui_callback:
57+
self._on_remove_from_ui_callback()
58+
5459
def _raise_if_attribute_not_declared_in_model(self, attribute: str) -> None:
5560
if attribute not in self.__class__.model_fields:
5661
msg = f"Attribute '{attribute}' is not part of the model fields"
@@ -67,3 +72,10 @@ def on_value_change(self, attribute: str, callback: Callable) -> None:
6772
self._raise_if_attribute_not_declared_in_model(attribute)
6873

6974
self._on_value_change_subscribers[attribute] = callback
75+
76+
def on_remove_from_ui(self, callback: Callable) -> None:
77+
"""
78+
invokes callback when object is no longer required,
79+
allows the UI to have a clear hook to remove the component
80+
"""
81+
self._on_remove_from_ui_callback = callback

services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/updatable_component.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from abc import ABC, abstractmethod
22
from typing import Generic, TypeVar
33

4+
from nicegui import ui
5+
from nicegui.element import Element
6+
47
from .base_display_model import BaseUpdatableDisplayModel
58

69
T = TypeVar("T", bound=BaseUpdatableDisplayModel)
@@ -10,6 +13,20 @@ class BaseUpdatableComponent(ABC, Generic[T]):
1013
def __init__(self, display_model: T):
1114
self.display_model = display_model
1215

16+
self._parent: Element | None = None
17+
self.display_model.on_remove_from_ui(self._remove_from_ui)
18+
19+
def _remove_from_ui(self) -> None:
20+
if self._parent is not None:
21+
self._parent.delete()
22+
self._parent = None
23+
24+
def display(self) -> None:
25+
if self._parent is None:
26+
self._parent = ui.element() # this is an empty div as a parent
27+
with self._parent:
28+
self._draw_ui()
29+
1330
@abstractmethod
14-
def add_to_ui(self) -> None:
31+
def _draw_ui(self) -> None:
1532
"""creates ui elements inside the parent container"""

services/dynamic-scheduler/tests/unit/api_frontend/_common/test_updatable_component.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import nicegui
88
import pytest
99
from fastapi import FastAPI
10-
from helpers import assert_contains_text
10+
from helpers import assert_contains_text, assert_not_contains_text
1111
from nicegui import APIRouter, ui
1212
from playwright.async_api import Page
1313
from pydantic import NonNegativeInt
@@ -90,7 +90,7 @@ def rerender_on_type_change(self) -> set[str]:
9090

9191

9292
class FriendComponent(BaseUpdatableComponent[Friend]):
93-
def add_to_ui(self) -> None:
93+
def _draw_ui(self) -> None:
9494
ui.label().bind_text_from(
9595
self.display_model,
9696
"name",
@@ -104,7 +104,7 @@ def add_to_ui(self) -> None:
104104

105105

106106
class PetComponent(BaseUpdatableComponent[Pet]):
107-
def add_to_ui(self) -> None:
107+
def _draw_ui(self) -> None:
108108
ui.label().bind_text_from(
109109
self.display_model,
110110
"name",
@@ -118,7 +118,7 @@ def add_to_ui(self) -> None:
118118

119119

120120
class PersonComponent(BaseUpdatableComponent[Person]):
121-
def add_to_ui(self) -> None:
121+
def _draw_ui(self) -> None:
122122
# NOTE:
123123
# There are 3 ways to bind the UI to the model changes:
124124
# 1. using nicegui builting facilties
@@ -147,18 +147,18 @@ def _person_age_ui() -> None:
147147
@ui.refreshable
148148
def _friend_or_pet_ui() -> None:
149149
if isinstance(self.display_model.companion, Friend):
150-
FriendComponent(self.display_model.companion).add_to_ui()
150+
FriendComponent(self.display_model.companion).display()
151151

152152
elif isinstance(self.display_model.companion, Pet):
153-
PetComponent(self.display_model.companion).add_to_ui()
153+
PetComponent(self.display_model.companion).display()
154154

155155
_friend_or_pet_ui()
156156
self.display_model.on_type_change("companion", _friend_or_pet_ui.refresh)
157157

158158

159159
def _index_page_ui(person: Person) -> None:
160160
ui.label("BEFORE_LABEL")
161-
PersonComponent(person).add_to_ui()
161+
PersonComponent(person).display()
162162
ui.label("AFTER_LABEL")
163163

164164

@@ -198,30 +198,57 @@ async def _ensure_index_page(async_page: Page, person: Person) -> None:
198198
await _ensure_after_label(async_page)
199199

200200

201+
async def _ensure_companion_not_present(async_page: Page) -> None:
202+
await assert_not_contains_text(async_page, "Pet Name: ")
203+
await assert_not_contains_text(async_page, "Pet Species: ")
204+
205+
await assert_not_contains_text(async_page, "Friend Name: ")
206+
await assert_not_contains_text(async_page, "Friend Age: ")
207+
208+
209+
async def _ensure_person_not_present(async_page: Page) -> None:
210+
await assert_not_contains_text(async_page, "Name: ")
211+
await assert_not_contains_text(async_page, "Age: ")
212+
213+
await _ensure_companion_not_present(async_page)
214+
215+
216+
def _get_updatable_display_model_ids(obj: BaseUpdatableDisplayModel) -> dict[int, str]:
217+
result: dict[int, str] = {id(obj): obj.__class__.__name__}
218+
for value in obj.__dict__.values():
219+
if isinstance(value, BaseUpdatableDisplayModel):
220+
result[id(value)] = value.__class__.__name__
221+
return result
222+
223+
201224
@pytest.mark.parametrize(
202-
"person, person_update, expected_callbacks_count",
225+
"person, person_update, expect_same_companion_object, expected_callbacks_count",
203226
[
204227
pytest.param(
205228
Person(name="Alice", age=30, companion=Pet(name="Fluffy", species="cat")),
206229
Person(name="Alice", age=30, companion=Pet(name="Buddy", species="dog")),
230+
True,
207231
0,
208232
id="update-pet-via-attribute-biding-no-rerender",
209233
),
210234
pytest.param(
211235
Person(name="Alice", age=30, companion=Pet(name="Fluffy", species="cat")),
212236
Person(name="Alice", age=30, companion=Friend(name="Marta", age=30)),
237+
False,
213238
1,
214239
id="update-pet-ui-via-rerednder-due-to-type-change",
215240
),
216241
pytest.param(
217242
Person(name="Alice", age=30, companion=Pet(name="Fluffy", species="cat")),
218243
Person(name="Bob", age=30, companion=Pet(name="Fluffy", species="cat")),
244+
True,
219245
0,
220246
id="change-person-name-via-bindings",
221247
),
222248
pytest.param(
223249
Person(name="Alice", age=30, companion=Pet(name="Fluffy", species="cat")),
224250
Person(name="Alice", age=31, companion=Pet(name="Fluffy", species="cat")),
251+
True,
225252
1,
226253
id="change-person-age-via-rerender-due-to-value-change",
227254
),
@@ -234,15 +261,35 @@ async def test_updatable_component(
234261
server_host_port: str,
235262
person: Person,
236263
person_update: Person,
264+
expect_same_companion_object: bool,
237265
expected_callbacks_count: NonNegativeInt,
238266
):
239267
await async_page.goto(f"{server_host_port}{mount_path}")
268+
print("✅ index page loaded")
240269

241270
# check initial page layout
242271
await _ensure_index_page(async_page, person)
243272

273+
before_update = _get_updatable_display_model_ids(person)
244274
callbacks_count = person.update(person_update)
275+
after_update = _get_updatable_display_model_ids(person)
276+
assert (before_update == after_update) is expect_same_companion_object
277+
245278
assert callbacks_count == expected_callbacks_count
246279

247280
# change layout after update
248281
await _ensure_index_page(async_page, person_update)
282+
283+
# REMOVE only the companion form UI
284+
person.companion.on_remove_from_ui()
285+
await _ensure_companion_not_present(async_page)
286+
287+
# TODO: remove below check screenshto margins
288+
await _ensure_index_page(async_page, person_update)
289+
290+
# REMOVE the person form UI
291+
person.on_remove_from_ui()
292+
await _ensure_person_not_present(async_page)
293+
294+
await _ensure_before_label(async_page)
295+
await _ensure_after_label(async_page)

0 commit comments

Comments
 (0)