diff --git a/CHANGELOG.md b/CHANGELOG.md index f828fc612..02fa380cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `playwright.controller.InputActionButton` gains a `expect_icon()` method. As a result, the already existing `expect_label()` no longer includes the icon. (#2020) +### Changes + +* `express.ui.insert_accordion_panel()`'s function signature has changed to be more ergonomic. Now you can pass the `panel_title` and `panel_contents` directly instead of `ui.hold()`ing the `ui.accordion_panel()` context manager. (#2042) + ### Improvements * Improved the styling and readability of markdown tables rendered by `ui.Chat()` and `ui.MarkdownStream()`. (#1973) diff --git a/shiny/api-examples/insert_accordion_panel/app-express.py b/shiny/api-examples/insert_accordion_panel/app-express.py index 1de832962..cac1f7ee5 100644 --- a/shiny/api-examples/insert_accordion_panel/app-express.py +++ b/shiny/api-examples/insert_accordion_panel/app-express.py @@ -1,20 +1,21 @@ import random -from shiny import reactive, ui -from shiny.express import input - - -def make_panel(letter): - return ui.accordion_panel( - f"Section {letter}", f"Some narrative for section {letter}" - ) - +from shiny import reactive +from shiny.express import input, ui ui.input_action_button("add_panel", "Add random panel", class_="mt-3 mb-3") -ui.accordion(*[make_panel(letter) for letter in "ABCDE"], id="acc", multiple=True) + +with ui.accordion(id="acc", multiple=True): + for letter in "ABCDE": + with ui.accordion_panel(f"Section {letter}"): + f"Some narrative for section {letter}" @reactive.effect @reactive.event(input.add_panel) def _(): - ui.insert_accordion_panel("acc", make_panel(str(random.randint(0, 10000)))) + ui.insert_accordion_panel( + "acc", + f"Section {random.randint(0, 10000)}", + f"Some narrative for section {random.randint(0, 10000)}", + ) diff --git a/shiny/express/ui/__init__.py b/shiny/express/ui/__init__.py index 4c3d61967..c78aa106e 100644 --- a/shiny/express/ui/__init__.py +++ b/shiny/express/ui/__init__.py @@ -1,15 +1,12 @@ from __future__ import annotations - from htmltools import ( - TagList, + HTML, Tag, - TagChild, TagAttrs, TagAttrValue, - tags, - HTML, - head_content, + TagChild, + TagList, a, br, code, @@ -21,116 +18,113 @@ h4, h5, h6, + head_content, hr, img, p, pre, span, strong, -) - -from ...ui import ( - fill, -) - -from ...ui import ( - busy_indicators, + tags, ) from ...ui import ( AccordionPanel, AnimationOptions, CardItem, + Progress, ShowcaseLayout, Sidebar, SliderStepArg, SliderValueArg, + Theme, ValueBoxTheme, + bind_task_button, brush_opts, + busy_indicators, click_opts, dblclick_opts, + fill, help_text, hover_opts, include_css, include_js, - input_bookmark_button, input_action_button, input_action_link, + input_bookmark_button, input_checkbox, input_checkbox_group, - input_switch, - input_radio_buttons, input_dark_mode, input_date, input_date_range, input_file, input_numeric, input_password, + input_radio_buttons, input_select, input_selectize, input_slider, - bind_task_button, + input_switch, input_task_button, input_text, input_text_area, + insert_ui, + js_eval, + markdown, + modal, + modal_button, + modal_remove, + modal_show, + nav_spacer, + navbar_options, + notification_remove, + notification_show, panel_title, - insert_accordion_panel, remove_accordion_panel, + remove_nav_panel, + remove_ui, update_accordion, update_accordion_panel, - update_sidebar, update_action_button, update_action_link, update_checkbox, - update_switch, update_checkbox_group, - update_radio_buttons, update_dark_mode, update_date, update_date_range, + update_nav_panel, + update_navs, update_numeric, + update_popover, + update_radio_buttons, update_select, update_selectize, + update_sidebar, update_slider, + update_switch, update_task_button, update_text, update_text_area, - update_navs, update_tooltip, - update_popover, - insert_ui, - remove_ui, - markdown, - modal_button, - modal, - modal_show, - modal_remove, - notification_show, - notification_remove, - nav_spacer, - navbar_options, - remove_nav_panel, - update_nav_panel, - Progress, - Theme, value_box_theme, - js_eval, ) - +from ...ui._chat import ChatExpress as Chat +from ...ui._markdown_stream import ( + ExpressMarkdownStream as MarkdownStream, +) from ._cm_components import ( - sidebar, - layout_sidebar, - layout_column_wrap, - layout_columns, + accordion, + accordion_panel, card, card_body, - card_header, card_footer, - accordion, - accordion_panel, - nav_panel, + card_header, + layout_column_wrap, + layout_columns, + layout_sidebar, nav_control, nav_menu, + nav_panel, navset_bar, navset_card_pill, navset_card_tab, @@ -140,32 +134,25 @@ navset_pill_list, navset_tab, navset_underline, - value_box, - panel_well, + panel_absolute, panel_conditional, panel_fixed, - panel_absolute, - tooltip, + panel_well, popover, + sidebar, + tooltip, + value_box, ) - -from ...ui._chat import ChatExpress as Chat - -from ...ui._markdown_stream import ( - ExpressMarkdownStream as MarkdownStream, -) - -from ._page import ( - page_opts, -) - from ._hold import ( hold, ) - from ._insert import ( + insert_accordion_panel, insert_nav_panel, ) +from ._page import ( + page_opts, +) __all__ = ( # Imports from htmltools diff --git a/shiny/express/ui/_insert.py b/shiny/express/ui/_insert.py index 91546a453..d29ae6804 100644 --- a/shiny/express/ui/_insert.py +++ b/shiny/express/ui/_insert.py @@ -7,12 +7,82 @@ @ui.hold() pass the UI as a value without displaying it. """ -from typing import Literal, Optional +from typing import Literal, Optional, Union -from htmltools import TagChild +from htmltools import TagAttrs, TagChild from ..._docstring import add_example from ...session import Session +from ...types import MISSING, MISSING_TYPE + + +@add_example() +def insert_accordion_panel( + id: str, + panel_title: str, + *panel_contents: Union[TagChild, TagAttrs], + panel_value: Union[str, MISSING_TYPE, None] = MISSING, + panel_icon: TagChild = None, + target: Optional[str] = None, + position: Literal["after", "before"] = "after", + session: Optional[Session] = None, +) -> None: + """ + Insert an accordion panel into an existing accordion. + + Parameters + ---------- + id + A string that matches an existing :func:`~shiny.ui.express.accordion`'s `id`. + panel_title + The title to appear in the panel header. + panel_contents + UI elements for the panel's body. Can also be a dict of tag attributes for the + body's HTML container. + panel_value + A character string that uniquely identifies this panel. If `MISSING`, the + `title` will be used. + panel_icon + A :class:`~htmltools.TagChild` which is positioned just before the `title`. + target + The `value` of an existing panel to insert next to. + position + Should `panel` be added before or after the target? When `target=None`, + `"after"` will append after the last panel and `"before"` will prepend before + the first panel. + session + A Shiny session object (the default should almost always be used). + + References + ---------- + [Bootstrap Accordion](https://getbootstrap.com/docs/5.3/components/accordion/) + + See Also + -------- + * :func:`~shiny.ui.express.accordion` + * :func:`~shiny.ui.express.accordion_panel` + * :func:`~shiny.ui.express.update_accordion` + """ + + from ...ui import AccordionPanel, accordion_panel, insert_accordion_panel + + if isinstance(panel_title, AccordionPanel): + # If the user passed an AccordionPanel, we can just use it as is. + # This isn't recommended, but we support it for backwards compatibility + # with the old API. + panel = panel_title + else: + panel = accordion_panel( + panel_title, *panel_contents, value=panel_value, icon=panel_icon + ) + + insert_accordion_panel( + id=id, + panel=panel, + target=target, + position=position, + session=session, + ) @add_example() diff --git a/shiny/ui/_accordion.py b/shiny/ui/_accordion.py index 2201b5eba..5d4ca72b5 100644 --- a/shiny/ui/_accordion.py +++ b/shiny/ui/_accordion.py @@ -315,17 +315,17 @@ def accordion_panel( Parameters ---------- title - A title to appear in the :func:`~shiny.ui.accordion_panel`'s header. + A title to appear in the panel's header. *args - Contents to the accordion panel body. Or tag attributes that are supplied to the - returned :class:`~htmltools.Tag` object. + UI elements for the panel's body. Can also be a dict of tag attributes for the + body's HTML container. value A character string that uniquely identifies this panel. If `MISSING`, the `title` will be used. icon - A :class:`~htmltools.Tag` which is positioned just before the `title`. + A :class:`~htmltools.TagChild` which is positioned just before the `title`. **kwargs - Tag attributes to the `accordion-body` div Tag. + Tag attributes for the body's HTML container. Returns ------- diff --git a/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/app-express.py b/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/app-express.py new file mode 100644 index 000000000..296643fe9 --- /dev/null +++ b/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/app-express.py @@ -0,0 +1,40 @@ +import datetime + +from shiny import reactive +from shiny.express import input, ui + +with ui.accordion(id="my_accordion"): + with ui.accordion_panel(title="About"): + "This is a simple Shiny app." + + with ui.accordion_panel(title="Panel 1"): + "Some initial content for Panel 1." + + with ui.accordion_panel(title="Panel 2", value="panel_2_val"): + "Some initial content for Panel 2." + +ui.input_action_button("update_button", "Update Panel 2") +ui.input_action_button("add_panel_button", "Add New Panel") + +panel_counter = reactive.value(3) + + +@reactive.effect +@reactive.event(input.update_button) +def _(): + new_content = f"Content updated at: {datetime.datetime.now().strftime('%H:%M:%S')}" + ui.update_accordion_panel( + "my_accordion", "panel_2_val", new_content, title="Panel 2 (Updated)" + ) + + +@reactive.effect +@reactive.event(input.add_panel_button) +def _(): + current_count = panel_counter.get() + panel_counter.set(current_count + 1) + ui.insert_accordion_panel( + "my_accordion", + f"Panel {current_count}", + f"This is dynamically added panel {current_count}, created at {datetime.datetime.now().strftime('%H:%M:%S')}", + ) diff --git a/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/test_app_express.py b/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/test_app_express.py new file mode 100644 index 000000000..b5eb120f9 --- /dev/null +++ b/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/test_app_express.py @@ -0,0 +1,49 @@ +from playwright.sync_api import Page + +from shiny.playwright import controller +from shiny.pytest import create_app_fixture +from shiny.run import ShinyAppProc + +app = create_app_fixture(["app-express.py"]) + + +def test_accordion_and_buttons(page: Page, app: ShinyAppProc) -> None: + page.goto(app.url) + + accordion = controller.Accordion(page, "my_accordion") + update_button = controller.InputActionButton(page, "update_button") + add_panel_button = controller.InputActionButton(page, "add_panel_button") + + accordion.expect_panels(["About", "Panel 1", "panel_2_val"]) + + about_panel = accordion.accordion_panel("About") + about_panel.expect_label("About") + about_panel.expect_body("This is a simple Shiny app.") + + panel_1 = accordion.accordion_panel("Panel 1") + panel_1.expect_label("Panel 1") + panel_1.expect_body("Some initial content for Panel 1.") + + panel_2 = accordion.accordion_panel("panel_2_val") + panel_2.expect_label("Panel 2") + panel_2.expect_body("Some initial content for Panel 2.") + + update_button.expect_label("Update Panel 2") + update_button.click() + + panel_2.expect_label("Panel 2 (Updated)") + + add_panel_button.expect_label("Add New Panel") + add_panel_button.click() + + accordion.expect_panels(["About", "Panel 1", "panel_2_val", "Panel 3"]) + + panel_3 = accordion.accordion_panel("Panel 3") + panel_3.expect_label("Panel 3") + + add_panel_button.click() + + accordion.expect_panels(["About", "Panel 1", "panel_2_val", "Panel 3", "Panel 4"]) + + panel_4 = accordion.accordion_panel("Panel 4") + panel_4.expect_label("Panel 4") diff --git a/tests/playwright/shiny/components/accordion/app.py b/tests/playwright/shiny/components/accordion/app.py index 5ee4d2b7a..f9b230cab 100644 --- a/tests/playwright/shiny/components/accordion/app.py +++ b/tests/playwright/shiny/components/accordion/app.py @@ -1,142 +1,147 @@ from __future__ import annotations -from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui - - -def make_panel(letter: str) -> ui.AccordionPanel: - return ui.accordion_panel( - f"Section {letter}", - f"Some narrative for section {letter}", - ) - - -items = [make_panel(letter) for letter in "ABCD"] - -accordion = ui.accordion(*items, id="acc") -app_ui = ui.page_fluid( - ui.tags.div( - ui.input_action_button("toggle_b", "Open/Close B"), - ui.input_action_button("open_all", "Open All"), - ui.input_action_button("close_all", "Close All"), - ui.input_action_button("alternate", "Alternate"), - ui.input_action_button("toggle_efg", "Add/Remove EFG"), - ui.input_action_button("toggle_updates", "Add/Remove Updates"), - class_="d-flex", - ), - ui.output_text_verbatim("acc_txt", placeholder=True), - accordion, -) - - -def server(input: Inputs, output: Outputs, session: Session) -> None: - @reactive.calc - def acc() -> list[str]: - acc_val: list[str] | None = input.acc() - if acc_val is None: - acc_val = [] - return acc_val - - @reactive.effect - def _(): - req(input.toggle_b()) +from shiny import reactive, render +from shiny.express import input, ui - with reactive.isolate(): - if "Section B" in acc(): - ui.update_accordion_panel("acc", "Section B", show=False) - else: - ui.update_accordion_panel("acc", "Section B", show=True) - - @reactive.effect - def _(): - req(input.open_all()) - ui.update_accordion("acc", show=True) - - @reactive.effect - def _(): - req(input.close_all()) - ui.update_accordion("acc", show=False) - - has_efg = False - has_alternate = True - has_updates = False - - @reactive.effect - def _(): - req(input.alternate()) - - sections = [ - "updated_section_a" if has_updates else "Section A", - "Section B", - "Section C", - "Section D", - ] - if has_efg: - sections.extend(["Section E", "Section F", "Section G"]) - - nonlocal has_alternate - val = int(has_alternate) - sections = [section for i, section in enumerate(sections) if i % 2 == val] - ui.update_accordion("acc", show=sections) - has_alternate = not has_alternate - - @reactive.effect - def _(): - req(input.toggle_efg()) - - nonlocal has_efg - if has_efg: - ui.remove_accordion_panel("acc", ["Section E", "Section F", "Section G"]) - else: - ui.insert_accordion_panel("acc", make_panel("E"), "Section D") - ui.insert_accordion_panel("acc", make_panel("F"), "Section E") - ui.insert_accordion_panel("acc", make_panel("G"), "Section F") - - has_efg = not has_efg - - @reactive.effect - def _(): - req(input.toggle_updates()) - - nonlocal has_updates - if has_updates: - ui.update_accordion_panel( - "acc", - "updated_section_a", - "Some narrative for section A", - title="Section A", - value="Section A", - icon="", - ) - else: - with reactive.isolate(): - # print(acc()) - if "Section A" not in acc(): - ui.notification_show("Opening Section A", duration=2) - ui.update_accordion_panel("acc", "Section A", show=True) - ui.update_accordion_panel( - "acc", - "Section A", - "Updated body", - value="updated_section_a", - title=ui.tags.h3("Updated title"), - icon=ui.tags.div( - "Look! An icon! -->", - ui.HTML( - """\ - - - - - """ - ), - ), - ) - has_updates = not has_updates +def make_panel_title(letter: str) -> str: + return f"Section {letter}" + + +def make_panel_content(letter: str) -> str: + return f"Some narrative for section {letter}" + + +with ui.tags.div(class_="d-flex"): + ui.input_action_button("toggle_b", "Open/Close B") + ui.input_action_button("open_all", "Open All") + ui.input_action_button("close_all", "Close All") + ui.input_action_button("alternate", "Alternate") + ui.input_action_button("toggle_efg", "Add/Remove EFG") + ui.input_action_button("toggle_updates", "Add/Remove Updates") + - @render.text - def acc_txt(): - return f"input.acc(): {input.acc()}" +@render.text +def acc_txt(): + return f"input.acc(): {input.acc()}" -app = App(app_ui, server) +with ui.accordion(id="acc"): + for letter in "ABCD": + with ui.accordion_panel(f"Section {letter}"): + f"Some narrative for section {letter}" + + +@reactive.calc +def acc() -> list[str]: + acc_val: list[str] | None = input.acc() + if acc_val is None: + acc_val = [] + return acc_val + + +@reactive.effect +@reactive.event(input.toggle_b) +def _(): + with reactive.isolate(): + if "Section B" in acc(): + ui.update_accordion_panel("acc", "Section B", show=False) + else: + ui.update_accordion_panel("acc", "Section B", show=True) + + +@reactive.effect +@reactive.event(input.open_all) +def _(): + ui.update_accordion("acc", show=True) + + +@reactive.effect +@reactive.event(input.close_all) +def _(): + ui.update_accordion("acc", show=False) + + +has_efg = False +has_alternate = True +has_updates = False + + +@reactive.effect +@reactive.event(input.alternate) +def _(): + sections = [ + "updated_section_a" if has_updates else "Section A", + "Section B", + "Section C", + "Section D", + ] + if has_efg: + sections.extend(["Section E", "Section F", "Section G"]) + + global has_alternate + val = int(has_alternate) + sections = [section for i, section in enumerate(sections) if i % 2 == val] + ui.update_accordion("acc", show=sections) + has_alternate = not has_alternate + + +@reactive.effect +@reactive.event(input.toggle_efg) +def _(): + global has_efg + if has_efg: + ui.remove_accordion_panel("acc", ["Section E", "Section F", "Section G"]) + else: + ui.insert_accordion_panel( + "acc", make_panel_title("E"), make_panel_content("E"), target="Section D" + ) + ui.insert_accordion_panel( + "acc", make_panel_title("F"), make_panel_content("F"), target="Section E" + ) + ui.insert_accordion_panel( + "acc", make_panel_title("G"), make_panel_content("G"), target="Section F" + ) + + has_efg = not has_efg + + +@reactive.effect +@reactive.event(input.toggle_updates) +def _(): + global has_updates + if has_updates: + ui.update_accordion_panel( + "acc", + "updated_section_a", + "Some narrative for section A", + title="Section A", + value="Section A", + icon="", + ) + else: + with reactive.isolate(): + # print(acc()) + if "Section A" not in acc(): + ui.notification_show("Opening Section A", duration=2) + ui.update_accordion_panel("acc", "Section A", show=True) + ui.update_accordion_panel( + "acc", + "Section A", + "Updated body", + value="updated_section_a", + title=ui.tags.h3("Updated title"), + icon=ui.tags.div( + "Look! An icon! -->", + ui.HTML( + """\ + + + + + """ + ), + ), + ) + + has_updates = not has_updates diff --git a/tests/playwright/shiny/components/accordion/test_accordion.py b/tests/playwright/shiny/components/accordion/test_accordion.py index f2d366c4b..18c14febb 100644 --- a/tests/playwright/shiny/components/accordion/test_accordion.py +++ b/tests/playwright/shiny/components/accordion/test_accordion.py @@ -12,7 +12,7 @@ def test_accordion(page: Page, local_app: ShinyAppProc) -> None: acc = controller.Accordion(page, "acc") acc_panel_A = acc.accordion_panel("Section A") - output_txt_verbatim = controller.OutputTextVerbatim(page, "acc_txt") + output_txt_verbatim = controller.OutputText(page, "acc_txt") alternate_button = controller.InputActionButton(page, "alternate") open_all_button = controller.InputActionButton(page, "open_all") close_all_button = controller.InputActionButton(page, "close_all")