From 352486382f6594ab11445c35a5b4e4626a8199eb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Jan 2025 13:51:23 +0100 Subject: [PATCH 1/4] Add css_id parameter to Viewable components --- panel/io/document.py | 15 +++++++++++++-- panel/layout/float.py | 2 +- panel/pane/base.py | 4 ++-- panel/reactive.py | 7 +++++-- panel/viewable.py | 3 +++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/panel/io/document.py b/panel/io/document.py index 9fd866ddd4..160b9a2e71 100644 --- a/panel/io/document.py +++ b/panel/io/document.py @@ -333,13 +333,24 @@ def init_doc(doc: Document | None) -> Document: if thread_id: state._thread_id_[curdoc] = thread_id + ready_callbacks = [CustomJS(code=""" + for (const v of cb_context.index.all_views()) { + for (const t of v.model.tags) { + if (typeof t === 'object' && t !== null && 'css_id' in t) { + v.el.id = t.css_id + } + } + } + """) + ] if config.global_loading_spinner: - curdoc.js_on_event( - 'document_ready', CustomJS(code=f""" + ready_callbacks.append( + CustomJS(code=f""" const body = document.getElementsByTagName('body')[0] body.classList.remove({LOADING_INDICATOR_CSS_CLASS!r}, {config.loading_spinner!r}) """) ) + curdoc.js_on_event('document_ready', *ready_callbacks) session_id = curdoc.session_context.id sessions = state.session_info['sessions'] diff --git a/panel/layout/float.py b/panel/layout/float.py index 7740b4fe67..daf0b5a238 100644 --- a/panel/layout/float.py +++ b/panel/layout/float.py @@ -86,7 +86,7 @@ class FloatPanel(ListLike, ReactiveHTML): """ - _rename = {'loading': None} + _rename = {'loading': None, 'css_id': None} _scripts = { "render": """ diff --git a/panel/pane/base.py b/panel/pane/base.py index 2ec5b00aca..22a765972e 100644 --- a/panel/pane/base.py +++ b/panel/pane/base.py @@ -299,7 +299,7 @@ class Pane(PaneBase, Reactive): # Mapping from parameter name to bokeh model property name _rename: ClassVar[Mapping[str, str | None]] = { - 'default_layout': None, 'loading': None + 'default_layout': None, 'loading': None, 'css_id': None } # List of parameters that trigger a rerender of the Bokeh model @@ -331,7 +331,7 @@ def _linkable_params(self) -> list[str]: @property def _synced_params(self) -> list[str]: ignored_params = [ - 'name', 'default_layout', 'loading', 'stylesheets' + 'name', 'default_layout', 'loading', 'stylesheets', 'css_id' ] + self._rerender_params return [p for p in self.param if p not in ignored_params and not p.startswith('_')] diff --git a/panel/reactive.py b/panel/reactive.py index f95ed0a7d3..cabb2091ae 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -249,7 +249,7 @@ def _synced_params(self) -> list[str]: Parameters which are synced with properties using transforms applied in the _process_param_change method. """ - ignored = ['default_layout', 'loading', 'background'] + ignored = ['default_layout', 'loading', 'background', 'css_id'] return [p for p in self.param if p not in self._manual_params+ignored] def _init_params(self) -> dict[str, Any]: @@ -621,7 +621,7 @@ class Reactive(Syncable, Viewable): _ignored_refs: ClassVar[tuple[str,...]] = () _rename: ClassVar[Mapping[str, str | None]] = { - 'design': None, 'loading': None + 'design': None, 'loading': None, 'css_id': None } __abstract = True @@ -665,6 +665,9 @@ def _get_properties(self, doc: Document | None) -> dict[str, Any]: params[k] = v = params[k] + v elif k not in params or self.param[k].default is not v: params[k] = v + if self.css_id: + tags = params.get('tags', []) + params['tags'] = tags + [{"css_id": self.css_id}] properties = self._process_param_change(params) if 'stylesheets' not in properties: return properties diff --git a/panel/viewable.py b/panel/viewable.py index 425567b673..ff0459cb8f 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -709,6 +709,9 @@ class Viewable(Renderable, Layoutable, ServableMixin): objects to be displayed in the notebook and on bokeh server. """ + css_id = param.String(default=None, constant=True, doc=""" + The id set on the rendered DOM node.""") + loading = param.Boolean(doc=""" Whether or not the Viewable is loading. If True a loading spinner is shown on top of the Viewable.""") From e63077c905d90c2fb4ec8b1279e619ee4612451c Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Jan 2025 15:01:39 +0100 Subject: [PATCH 2/4] Ensure ids are updated on model updates --- panel/io/document.py | 11 ++++++++++- panel/tests/test_reactive.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/panel/io/document.py b/panel/io/document.py index 160b9a2e71..d3c31def5b 100644 --- a/panel/io/document.py +++ b/panel/io/document.py @@ -334,13 +334,22 @@ def init_doc(doc: Document | None) -> Document: state._thread_id_[curdoc] = thread_id ready_callbacks = [CustomJS(code=""" - for (const v of cb_context.index.all_views()) { + const doc = cb_context.index.roots[0].model.document + const write_ids = () => { + for (const v of cb_context.index.all_views()) { for (const t of v.model.tags) { if (typeof t === 'object' && t !== null && 'css_id' in t) { v.el.id = t.css_id } } + } } + doc.on_change((event) => { + if (event.kind == 'ModelChanged') { + setTimeout(write_ids, 100) + } + }) + write_ids() """) ] if config.global_loading_spinner: diff --git a/panel/tests/test_reactive.py b/panel/tests/test_reactive.py index e04e7abd7f..77715e9fac 100644 --- a/panel/tests/test_reactive.py +++ b/panel/tests/test_reactive.py @@ -157,7 +157,7 @@ def test_text_input_controls(): assert isinstance(wb2, WidgetBox) params1 = {w.name.replace(" ", "_").lower() for w in wb2 if len(w.name)} - params2 = set(Viewable.param) - {"background", "design", "stylesheets", "loading"} + params2 = set(Viewable.param) - {"background", "design", "stylesheets", "loading", "css_id"} # Background should be moved when Layoutable.background is removed. assert not len(params1 - params2) From 3d4de58e8f53f71f405a35d9a53f1a04262f8c1e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Jan 2025 15:06:48 +0100 Subject: [PATCH 3/4] Add tests --- panel/tests/ui/pane/test_markup.py | 17 +++++++++++++++++ panel/tests/ui/test_custom.py | 13 +++++++++++++ panel/tests/ui/test_reactive.py | 7 +++++++ 3 files changed, 37 insertions(+) diff --git a/panel/tests/ui/pane/test_markup.py b/panel/tests/ui/pane/test_markup.py index 71399b79d0..1f90ded409 100644 --- a/panel/tests/ui/pane/test_markup.py +++ b/panel/tests/ui/pane/test_markup.py @@ -25,6 +25,23 @@ def test_update_markdown_pane(page): expect(page.locator(".markdown").locator("div")).to_have_text('Updated\n') +def test_markdown_pane_css_id(page): + md = Markdown('Initial', css_id='foo') + + serve_component(page, md) + + expect(page.locator("#foo").locator("div")).to_have_text('Initial\n') + +def test_markdown_pane_css_id_added(page): + md = Markdown('Initial', css_id='foo') + row = Row() + + serve_component(page, row) + + row.append(md) + + expect(page.locator("#foo").locator("div")).to_have_text('Initial\n') + def test_update_markdown_pane_empty(page): md = Markdown('Initial') diff --git a/panel/tests/ui/test_custom.py b/panel/tests/ui/test_custom.py index 06cf4b64be..a31ff17069 100644 --- a/panel/tests/ui/test_custom.py +++ b/panel/tests/ui/test_custom.py @@ -74,6 +74,19 @@ def test_update(page, component): expect(page.locator('h1')).to_have_text('Foo!') +@pytest.mark.parametrize('component', [JSUpdate, ReactUpdate, AnyWidgetUpdate]) +def test_css_id(page, component): + example = component(text='Hello World!', css_id='foo') + + serve_component(page, example) + + expect(page.locator('#foo')).to_have_text('Hello World!') + + example.text = "Foo!" + + expect(page.locator('#foo')).to_have_text('Foo!') + + class AnyWidgetInitialize(AnyWidgetComponent): count = param.Integer(default=0) diff --git a/panel/tests/ui/test_reactive.py b/panel/tests/ui/test_reactive.py index d64ad6906c..987eb8f8b8 100644 --- a/panel/tests/ui/test_reactive.py +++ b/panel/tests/ui/test_reactive.py @@ -71,6 +71,13 @@ def test_reactive_html_param_event(page): wait_until(lambda: component.count == 5, page) +def test_reactive_html_css_id(page): + component = ReactiveComponent(css_id='foo') + + serve_component(page, component) + + expect(page.locator("#foo")).to_have_text('1') + def test_reactive_html_set_loading_no_rerender(page): component = ReactiveComponent() From 1e84dd5b745c49bb4a8a22833a867597476c6753 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Jan 2025 15:19:58 +0100 Subject: [PATCH 4/4] Debounce --- panel/io/document.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/panel/io/document.py b/panel/io/document.py index d3c31def5b..4d454998fd 100644 --- a/panel/io/document.py +++ b/panel/io/document.py @@ -334,6 +334,7 @@ def init_doc(doc: Document | None) -> Document: state._thread_id_[curdoc] = thread_id ready_callbacks = [CustomJS(code=""" + let timeout = null const doc = cb_context.index.roots[0].model.document const write_ids = () => { for (const v of cb_context.index.all_views()) { @@ -343,10 +344,14 @@ def init_doc(doc: Document | None) -> Document: } } } + timeout = null } doc.on_change((event) => { if (event.kind == 'ModelChanged') { - setTimeout(write_ids, 100) + if (timeout !== null) { + clearTimeout(timeout) + } + timeout = setTimeout(write_ids, 100) } }) write_ids()