From 019f01528de3c60a6f8f58903343616e689a7e00 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Thu, 3 Oct 2024 16:34:12 -0700 Subject: [PATCH 01/33] start adding full calendar --- panel/models/fullcalendar.ts | 14 ++++++++++++++ panel/widgets/__init__.py | 2 ++ panel/widgets/calendar.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 panel/models/fullcalendar.ts create mode 100644 panel/widgets/calendar.py diff --git a/panel/models/fullcalendar.ts b/panel/models/fullcalendar.ts new file mode 100644 index 0000000000..9847f50622 --- /dev/null +++ b/panel/models/fullcalendar.ts @@ -0,0 +1,14 @@ +import { Calendar } from '@fullcalendar/core'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import timeGridPlugin from '@fullcalendar/timegrid'; + +export function render({ model, el }) { + let calendar = new Calendar(el, { + plugins: [dayGridPlugin, timeGridPlugin], + + initialView: model.initial_view, + events: model.value, + }); + + calendar.render(); +} diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index 02d8180038..d9377aaa65 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -34,6 +34,7 @@ """ from .base import CompositeWidget, Widget, WidgetBase # noqa from .button import Button, MenuButton, Toggle # noqa +from .calendar import Calendar # noqa from .codeeditor import CodeEditor # noqa from .debugger import Debugger # noqa from .file_selector import FileSelector # noqa @@ -74,6 +75,7 @@ "BooleanStatus", "Button", "ButtonIcon", + "Calendar", "Checkbox", "CheckBoxGroup", "CheckButtonGroup", diff --git a/panel/widgets/calendar.py b/panel/widgets/calendar.py new file mode 100644 index 0000000000..e0f309e1a9 --- /dev/null +++ b/panel/widgets/calendar.py @@ -0,0 +1,36 @@ +from pathlib import Path + +import param + +from panel.custom import JSComponent + +THIS_DIR = Path(__file__).parent +MODELS_DIR = THIS_DIR.parent / "models" + + +class Calendar(JSComponent): + + value = param.List(default=[], item_type=dict) + + initial_view = param.Selector( + default="dayGridMonth", objects=["dayGridMonth", "timeGridWeek", "timeGridDay"] + ) + + selectable = param.Boolean(default=True) + + editable = param.Boolean(default=True) + + event_limit = param.Integer(default=3) # Limit for event rendering + + _esm = MODELS_DIR / "fullcalendar.ts" + + _importmap = { + "imports": { + "@fullcalendar/core": "https://cdn.skypack.dev/@fullcalendar/core@6.1.15", + "@fullcalendar/daygrid": "https://cdn.skypack.dev/@fullcalendar/daygrid@6.1.15", + "@fullcalendar/timegrid": "https://cdn.skypack.dev/@fullcalendar/timegrid@6.1.15", + } + } + + def add_event(self, start: str, end: str | None = None, title: str = "(no title)", all_day: bool = False, **kwargs): + self.value.append({"start": start, "end": end, "title": title, **kwargs}) From 458f8ed7fdd372cf399b030b00c53fca8a475ec4 Mon Sep 17 00:00:00 2001 From: ahuang11 Date: Thu, 3 Oct 2024 18:30:12 -0700 Subject: [PATCH 02/33] add many view parrams --- panel/models/fullcalendar.js | 92 +++++++++++++++ panel/models/fullcalendar.ts | 14 --- panel/widgets/calendar.py | 223 +++++++++++++++++++++++++++++++++-- 3 files changed, 307 insertions(+), 22 deletions(-) create mode 100644 panel/models/fullcalendar.js delete mode 100644 panel/models/fullcalendar.ts diff --git a/panel/models/fullcalendar.js b/panel/models/fullcalendar.js new file mode 100644 index 0000000000..7904ff1e48 --- /dev/null +++ b/panel/models/fullcalendar.js @@ -0,0 +1,92 @@ +import { Calendar } from '@fullcalendar/core'; + +export function render({ model, el }) { + function createCalendar(plugins) { + let calendar = new Calendar(el, { + businessHours: model.business_hours, + buttonIcons: model.button_icons, + buttonText: model.button_text, + contentHeight: model.content_height, + dateAlignment: model.date_alignment, + dateIncrement: model.date_increment, + events: model.value, + expandRows: model.expand_rows, + footerToolbar: model.footer_toolbar, + handleWindowResize: model.handle_window_resize, + headerToolbar: model.header_toolbar, + initialDate: model.initial_date, + initialView: model.initial_view, + multiMonthMaxColumns: model.multi_month_max_columns, + nowIndicator: model.now_indicator, + plugins: plugins, + showNonCurrentDates: model.show_non_current_dates, + stickyFooterScrollbar: model.sticky_footer_scrollbar, + stickyHeaderDates: model.sticky_header_dates, + titleFormat: model.title_format, + titleRangeSeparator: model.title_range_separator, + validRange: model.valid_range, + windowResizeDelay: model.window_resize_delay, + datesSet: function (info) { + model.send_msg({ 'current_date': calendar.getDate().toISOString() }); + }, + }); + + if (model.aspect_ratio) { + calendar.setOption('aspectRatio', model.aspect_ratio); + } + + model.on("msg:custom", (event) => { + if (event.type === 'next') { + calendar.next(); + } + else if (event.type === 'prev') { + calendar.prev(); + } + else if (event.type === 'prevYear') { + calendar.prevYear(); + } + else if (event.type === 'nextYear') { + calendar.nextYear(); + } + else if (event.type === 'today') { + calendar.today(); + } + else if (event.type === 'gotoDate') { + calendar.gotoDate(event.date); + } + else if (event.type === 'incrementDate') { + calendar.incrementDate(event.increment); + } + else if (event.type === 'updateSize') { + calendar.updateSize(); + } + else if (event.type === 'updateOption') { + calendar.setOption(event.key, event.value); + } + }); + calendar.render(); + } + + let plugins = []; + function loadPluginIfNeeded(viewName, pluginName) { + if (model.initial_view.startsWith(viewName) || + (model.header_toolbar && Object.values(model.header_toolbar).some(v => v.includes(viewName))) || + (model.footer_toolbar && Object.values(model.footer_toolbar).some(v => v.includes(viewName)))) { + return import(`@fullcalendar/${pluginName}`).then(plugin => { + plugins.push(plugin.default); + return plugin.default; + }); + } + return Promise.resolve(null); + } + + Promise.all([ + loadPluginIfNeeded('dayGrid', 'daygrid'), + loadPluginIfNeeded('timeGrid', 'timegrid'), + loadPluginIfNeeded('list', 'list'), + loadPluginIfNeeded('multiMonth', 'multimonth') + ]).then(loadedPlugins => { + const filteredPlugins = loadedPlugins.filter(plugin => plugin !== null); + createCalendar(filteredPlugins); + }); +} \ No newline at end of file diff --git a/panel/models/fullcalendar.ts b/panel/models/fullcalendar.ts deleted file mode 100644 index 9847f50622..0000000000 --- a/panel/models/fullcalendar.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Calendar } from '@fullcalendar/core'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import timeGridPlugin from '@fullcalendar/timegrid'; - -export function render({ model, el }) { - let calendar = new Calendar(el, { - plugins: [dayGridPlugin, timeGridPlugin], - - initialView: model.initial_view, - events: model.value, - }); - - calendar.render(); -} diff --git a/panel/widgets/calendar.py b/panel/widgets/calendar.py index e0f309e1a9..04083ee159 100644 --- a/panel/widgets/calendar.py +++ b/panel/widgets/calendar.py @@ -1,3 +1,4 @@ +import datetime from pathlib import Path import param @@ -10,27 +11,233 @@ class Calendar(JSComponent): - value = param.List(default=[], item_type=dict) + aspect_ratio = param.Number( + default=None, doc="Sets the width-to-height aspect ratio of the calendar." + ) + + business_hours = param.Dict( + default={}, doc="Emphasizes certain time slots on the calendar." + ) + + button_icons = param.Dict( + default={}, + doc="Icons that will be displayed in buttons of the header/footer toolbar.", + ) + + button_text = param.Dict( + default={}, + doc="Text that will be displayed on buttons of the header/footer toolbar.", + ) + + content_height = param.String( + default=None, doc="Sets the height of the view area of the calendar." + ) + + current_date = param.Date( + default=None, doc="The current date of the calendar view." + ) + + date_alignment = param.String( + default="month", doc="Determines how certain views should be initially aligned." + ) + + date_increment = param.String( + default=None, + doc="The duration to move forward/backward when prev/next is clicked.", + ) + + expand_rows = param.Boolean( + default=False, + doc="If the rows of a given view don't take up the entire height, they will expand to fit.", + ) + + footer_toolbar = param.Dict( + default={}, doc="Defines the buttons and title at the bottom of the calendar." + ) + + handle_window_resize = param.Boolean( + default=True, + doc="Whether to automatically resize the calendar when the browser window resizes.", + ) + + header_toolbar = param.Dict( + default={ + "left": "prev,next today", + "center": "title", + "right": "dayGridMonth,timeGridWeek,timeGridDay,listWeek", + }, + doc="Defines the buttons and title at the top of the calendar.", + ) + + initial_date = param.Date( + default=None, + doc="The initial date the calendar should display when first loaded.", + ) initial_view = param.Selector( - default="dayGridMonth", objects=["dayGridMonth", "timeGridWeek", "timeGridDay"] + default="dayGridMonth", + objects=[ + "dayGridMonth", + "dayGridWeek", + "dayGridDay", + "timeGridWeek", + "timeGridDay", + "listWeek", + "listMonth", + "listYear", + "multiMonthYear", + ], + doc="The initial view when the calendar loads.", ) - selectable = param.Boolean(default=True) + multi_month_max_columns = param.Integer( + default=1, + doc="Determines the maximum number of columns in the multi-month view.", + ) - editable = param.Boolean(default=True) + now_indicator = param.Boolean( + default=False, doc="Whether to display an indicator for the current time." + ) - event_limit = param.Integer(default=3) # Limit for event rendering + show_non_current_dates = param.Boolean( + default=False, + doc="Whether to display dates in the current view that don't belong to the current month.", + ) - _esm = MODELS_DIR / "fullcalendar.ts" + sticky_footer_scrollbar = param.Boolean( + default=True, + doc="Whether to fix the view's horizontal scrollbar to the bottom of the viewport while scrolling.", + ) + + sticky_header_dates = param.String( + default=None, + doc="Whether to fix the date-headers at the top of the calendar to the viewport while scrolling.", + ) + + title_format = param.String( + default=None, + doc="Determines the text that will be displayed in the header toolbar's title.", + ) + + title_range_separator = param.String( + default=" to ", + doc="Determines the separator text when formatting the date range in the toolbar title.", + ) + + valid_range = param.DateRange( + default=None, + bounds=(None, None), + doc="Limits the range of time the calendar will display.", + ) + + value = param.List( + default=[], item_type=dict, doc="List of events to display on the calendar." + ) + + window_resize_delay = param.Integer( + default=100, + doc="The time the calendar will wait to adjust its size after a window resize occurs, in milliseconds.", + ) + + _esm = MODELS_DIR / "fullcalendar.js" + + _syncing = param.Boolean(default=False) _importmap = { "imports": { "@fullcalendar/core": "https://cdn.skypack.dev/@fullcalendar/core@6.1.15", "@fullcalendar/daygrid": "https://cdn.skypack.dev/@fullcalendar/daygrid@6.1.15", "@fullcalendar/timegrid": "https://cdn.skypack.dev/@fullcalendar/timegrid@6.1.15", + "@fullcalendar/list": "https://cdn.skypack.dev/@fullcalendar/list@6.1.15", + "@fullcalendar/multimonth": "https://cdn.skypack.dev/@fullcalendar/multimonth@6.1.15", } } - def add_event(self, start: str, end: str | None = None, title: str = "(no title)", all_day: bool = False, **kwargs): - self.value.append({"start": start, "end": end, "title": title, **kwargs}) + def __init__(self, **params): + super().__init__(**params) + self.param.watch( + self._update_option, + [ + "aspect_ratio", + "business_hours", + "button_icons", + "button_text", + "content_height", + "date_alignment", + "date_increment", + "expand_rows", + "footer_toolbar", + "handle_window_resize", + "header_toolbar", + "multi_month_max_columns", + "now_indicator", + "show_non_current_dates", + "sticky_footer_scrollbar", + "sticky_header_dates", + "title_format", + "title_range_separator", + "valid_range", + "value", + "window_resize_delay", + ], + ) + + def next(self): + self._send_msg({"type": "next"}) + + def prev(self): + self._send_msg({"type": "prev"}) + + def prev_year(self): + self._send_msg({"type": "prevYear"}) + + def next_year(self): + self._send_msg({"type": "nextYear"}) + + def today(self): + self._send_msg({"type": "today"}) + + def go_to_date(self, date): + self._send_msg({"type": "gotoDate", "date": date.isoformat()}) + + def increment_date(self, increment): + self._send_msg({"type": "incrementDate", "increment": increment}) + + def update_size(self): + self._send_msg({"type": "updateSize"}) + + def _handle_msg(self, msg): + if "current_date" in msg: + self.current_date = datetime.datetime.strptime( + msg["current_date"], "%Y-%m-%dT%H:%M:%S.%fZ" + ) + else: + raise NotImplementedError(f"Unhandled message: {msg}") + + def add_event( + self, + start: str, + end: str | None = None, + title: str = "(no title)", + all_day: bool = False, + **kwargs, + ): + event = { + "start": start, + "end": end, + "title": title, + "allDay": all_day, + **kwargs, + } + self.value.append(event) + + def _update_option(self, event): + def to_camel_case(string): + return "".join( + word.capitalize() if i else word + for i, word in enumerate(string.split("_")) + ) + key = to_camel_case(event.name) + self._send_msg( + {"type": "updateOption", "key": key, "value": event.new} + ) From 8687fe14e110a59feb356a9d9efe6d7a6251e913 Mon Sep 17 00:00:00 2001 From: ahuang11 Date: Thu, 3 Oct 2024 18:34:03 -0700 Subject: [PATCH 03/33] lint --- panel/models/fullcalendar.js | 90 ++++++++++++++++-------------------- panel/widgets/calendar.py | 1 + 2 files changed, 42 insertions(+), 49 deletions(-) diff --git a/panel/models/fullcalendar.js b/panel/models/fullcalendar.js index 7904ff1e48..532d8dae93 100644 --- a/panel/models/fullcalendar.js +++ b/panel/models/fullcalendar.js @@ -1,8 +1,8 @@ -import { Calendar } from '@fullcalendar/core'; +import {Calendar} from "@fullcalendar/core" -export function render({ model, el }) { +export function render({model, el}) { function createCalendar(plugins) { - let calendar = new Calendar(el, { + const calendar = new Calendar(el, { businessHours: model.business_hours, buttonIcons: model.button_icons, buttonText: model.button_text, @@ -18,7 +18,7 @@ export function render({ model, el }) { initialView: model.initial_view, multiMonthMaxColumns: model.multi_month_max_columns, nowIndicator: model.now_indicator, - plugins: plugins, + plugins, showNonCurrentDates: model.show_non_current_dates, stickyFooterScrollbar: model.sticky_footer_scrollbar, stickyHeaderDates: model.sticky_header_dates, @@ -26,67 +26,59 @@ export function render({ model, el }) { titleRangeSeparator: model.title_range_separator, validRange: model.valid_range, windowResizeDelay: model.window_resize_delay, - datesSet: function (info) { - model.send_msg({ 'current_date': calendar.getDate().toISOString() }); + datesSet(info) { + model.send_msg({current_date: calendar.getDate().toISOString()}) }, - }); + }) if (model.aspect_ratio) { - calendar.setOption('aspectRatio', model.aspect_ratio); + calendar.setOption("aspectRatio", model.aspect_ratio) } model.on("msg:custom", (event) => { - if (event.type === 'next') { - calendar.next(); + if (event.type === "next") { + calendar.next() + } else if (event.type === "prev") { + calendar.prev() + } else if (event.type === "prevYear") { + calendar.prevYear() + } else if (event.type === "nextYear") { + calendar.nextYear() + } else if (event.type === "today") { + calendar.today() + } else if (event.type === "gotoDate") { + calendar.gotoDate(event.date) + } else if (event.type === "incrementDate") { + calendar.incrementDate(event.increment) + } else if (event.type === "updateSize") { + calendar.updateSize() + } else if (event.type === "updateOption") { + calendar.setOption(event.key, event.value) } - else if (event.type === 'prev') { - calendar.prev(); - } - else if (event.type === 'prevYear') { - calendar.prevYear(); - } - else if (event.type === 'nextYear') { - calendar.nextYear(); - } - else if (event.type === 'today') { - calendar.today(); - } - else if (event.type === 'gotoDate') { - calendar.gotoDate(event.date); - } - else if (event.type === 'incrementDate') { - calendar.incrementDate(event.increment); - } - else if (event.type === 'updateSize') { - calendar.updateSize(); - } - else if (event.type === 'updateOption') { - calendar.setOption(event.key, event.value); - } - }); - calendar.render(); + }) + calendar.render() } - let plugins = []; + const plugins = [] function loadPluginIfNeeded(viewName, pluginName) { if (model.initial_view.startsWith(viewName) || (model.header_toolbar && Object.values(model.header_toolbar).some(v => v.includes(viewName))) || (model.footer_toolbar && Object.values(model.footer_toolbar).some(v => v.includes(viewName)))) { return import(`@fullcalendar/${pluginName}`).then(plugin => { - plugins.push(plugin.default); - return plugin.default; - }); + plugins.push(plugin.default) + return plugin.default + }) } - return Promise.resolve(null); + return Promise.resolve(null) } Promise.all([ - loadPluginIfNeeded('dayGrid', 'daygrid'), - loadPluginIfNeeded('timeGrid', 'timegrid'), - loadPluginIfNeeded('list', 'list'), - loadPluginIfNeeded('multiMonth', 'multimonth') + loadPluginIfNeeded("dayGrid", "daygrid"), + loadPluginIfNeeded("timeGrid", "timegrid"), + loadPluginIfNeeded("list", "list"), + loadPluginIfNeeded("multiMonth", "multimonth"), ]).then(loadedPlugins => { - const filteredPlugins = loadedPlugins.filter(plugin => plugin !== null); - createCalendar(filteredPlugins); - }); -} \ No newline at end of file + const filteredPlugins = loadedPlugins.filter(plugin => plugin !== null) + createCalendar(filteredPlugins) + }) +} diff --git a/panel/widgets/calendar.py b/panel/widgets/calendar.py index 04083ee159..9b93bc8203 100644 --- a/panel/widgets/calendar.py +++ b/panel/widgets/calendar.py @@ -1,4 +1,5 @@ import datetime + from pathlib import Path import param From 47851cc52b37d658e650bc24fbc435529875ae9d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 3 Oct 2024 23:41:20 +0200 Subject: [PATCH 04/33] Update CHANGELOG --- CHANGELOG.md | 25 ++++++++++++++++++++++++- doc/about/releases.md | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb84d627ef..9562df7ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,29 @@ # Releases +## Version 1.5.2 + +Date: 2024-10-03 + +This release is a small patch release primarily addressing a regression handling selections on multi-indexed data on the Tabulator widget along with a number of documentation improvements. Many thanks to @jrycw along with @MarcSkovMadsen, @Hoxbro and @philippjfr for their contributions to this release. + +### Bug fixes + +- Fix regression handling selection on multi-indexed `Tabulator` data ([#7336](https://github.com/holoviz/panel/pull/7336)) +- Fix Fast template theme toggle color ([#7341](https://github.com/holoviz/panel/pull/7341)) + +### Documentation + +- Fix typo for FastListTemplate reference example ([#7339](https://github.com/holoviz/panel/pull/7339)) +- Fix custom component docstrings ([#7342](https://github.com/holoviz/panel/pull/7342)) +- Improve plotly style guide ([#7346](https://github.com/holoviz/panel/pull/7346)) +- Ensure sidebar toggle patch is loaded correctly ([#7349](https://github.com/holoviz/panel/pull/7349)) +- Fix and update WebLLM example ([#7351](https://github.com/holoviz/panel/pull/7351)) +- Correctly document step unit on `DateSlider` ([#7353](https://github.com/holoviz/panel/pull/7353)) + ## Version 1.5.1 +Date: 2024-09-27 + This release primarily focuses on a number of tweaks and enhancements for the documentation and also resolves a number of bugs related to `Tabulator` rendering and the new `FastAPI` integration. As always, many thanks to our contributors including our new contributors @dennisjlee and @alexcjohnson, our returning contributors @thuydotm and @jbednar and the core maintainer team @hoxbro, @maximlt, @ahuang11, @MarcSkovMadsen and @philippjfr. ### Enhancements @@ -41,9 +63,10 @@ This release primarily focuses on a number of tweaks and enhancements for the do - Tweak best practices ([#7301](https://github.com/holoviz/panel/pull/7301)) - Fix typo in JPG component docs ([#7323](https://github.com/holoviz/panel/pull/7323)) - ## Version 1.5.0 +Date: 2024-09-13 + This release, while technically a minor release hugely expands the scope of what is possible in Panel. In particular the introduction of the new `panel.custom` module makes it trivially easy to create new JS and React based components using modern tooling, a first-class developer experience and support for compilation and bundling. We are incredibly excited to see which new components you build using this approach. This release also includes native integration with FastAPI, such that you can now run Panel apps natively on an existing FastAPI server. We also introduce a number of new components, improved the developer experience, and squashed a huge number of bugs, particularly for the `Tabulator` component. We really appreciate all the work that went into this release from 21 separate contributors. In particular we would like to welcome our new contributors @twobitunicorn, @justinwiley, @dwr-psandhu, @jordansamuels, @gandhis1, @jeffrey-hicks, @kdheepak, @sjdemartini, @alfredocarella and @pmeier. Next we want to recognize our returning contributors @cdeil, @Coderambling, @jrycw and @TBym, and finally the dedicated crew of core contributors which includes @Hoxbro, @MarcSkovMadsen, @ahuang11, @maximlt, @mattpap, @jbednar and @philippjfr. diff --git a/doc/about/releases.md b/doc/about/releases.md index 6f5526a86d..8ca3667cf5 100644 --- a/doc/about/releases.md +++ b/doc/about/releases.md @@ -2,8 +2,30 @@ See [the HoloViz blog](https://blog.holoviz.org/#category=panel) for a visual summary of the major features added in each release. +## Version 1.5.2 + +Date: 2024-10-03 + +This release is a small patch release primarily addressing a regression handling selections on multi-indexed data on the Tabulator widget along with a number of documentation improvements. Many thanks to @jrycw along with @MarcSkovMadsen, @Hoxbro and @philippjfr for their contributions to this release. + +### Bug fixes + +- Fix regression handling selection on multi-indexed `Tabulator` data ([#7336](https://github.com/holoviz/panel/pull/7336)) +- Fix Fast template theme toggle color ([#7341](https://github.com/holoviz/panel/pull/7341)) + +### Documentation + +- Fix typo for FastListTemplate reference example ([#7339](https://github.com/holoviz/panel/pull/7339)) +- Fix custom component docstrings ([#7342](https://github.com/holoviz/panel/pull/7342)) +- Improve plotly style guide ([#7346](https://github.com/holoviz/panel/pull/7346)) +- Ensure sidebar toggle patch is loaded correctly ([#7349](https://github.com/holoviz/panel/pull/7349)) +- Fix and update WebLLM example ([#7351](https://github.com/holoviz/panel/pull/7351)) +- Correctly document step unit on `DateSlider` ([#7353](https://github.com/holoviz/panel/pull/7353)) + ## Version 1.5.1 +Date: 2024-09-27 + This release primarily focuses on a number of tweaks and enhancements for the documentation and also resolves a number of bugs related to `Tabulator` rendering and the new `FastAPI` integration. As always, many thanks to our contributors including our new contributors @dennisjlee and @alexcjohnson, our returning contributors @thuydotm and @jbednar and the core maintainer team @hoxbro, @maximlt, @ahuang11, @MarcSkovMadsen and @philippjfr. ### Enhancements @@ -45,6 +67,8 @@ This release primarily focuses on a number of tweaks and enhancements for the do ## Version 1.5.0 +Date: 2024-09-13 + This release, while technically a minor release hugely expands the scope of what is possible in Panel. In particular the introduction of the new `panel.custom` module makes it trivially easy to create new JS and React based components using modern tooling, a first-class developer experience and support for compilation and bundling. We are incredibly excited to see which new components you build using this approach. This release also includes native integration with FastAPI, such that you can now run Panel apps natively on an existing FastAPI server. We also introduce a number of new components, improved the developer experience, and squashed a huge number of bugs, particularly for the `Tabulator` component. We really appreciate all the work that went into this release from 21 separate contributors. In particular we would like to welcome our new contributors @twobitunicorn, @justinwiley, @dwr-psandhu, @jordansamuels, @gandhis1, @jeffrey-hicks, @kdheepak, @sjdemartini, @alfredocarella and @pmeier. Next we want to recognize our returning contributors @cdeil, @Coderambling, @jrycw and @TBym, and finally the dedicated crew of core contributors which includes @Hoxbro, @MarcSkovMadsen, @ahuang11, @maximlt, @mattpap, @jbednar and @philippjfr. From d1a754f98dcdf9128bab8278216167178b36b49f Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 3 Oct 2024 23:41:59 +0200 Subject: [PATCH 05/33] Bump panel.js version to 1.5.2 --- panel/package-lock.json | 4 ++-- panel/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/package-lock.json b/panel/package-lock.json index 5dfd6d8fe3..2c28446633 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -1,12 +1,12 @@ { "name": "@holoviz/panel", - "version": "1.5.2-a.0", + "version": "1.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@holoviz/panel", - "version": "1.5.2-a.0", + "version": "1.5.2", "license": "BSD-3-Clause", "dependencies": { "@bokeh/bokehjs": "3.6.0", diff --git a/panel/package.json b/panel/package.json index 5f4e4ced23..d03fcdd0c2 100644 --- a/panel/package.json +++ b/panel/package.json @@ -1,6 +1,6 @@ { "name": "@holoviz/panel", - "version": "1.5.2-a.0", + "version": "1.5.2", "description": "The powerful data exploration & web app framework for Python.", "license": "BSD-3-Clause", "repository": { From ca45454bf68a6d80b60dcb1818da218e34daeb24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 7 Oct 2024 11:12:45 +0200 Subject: [PATCH 06/33] chore: Update pre-commit (#7366) --- .pre-commit-config.yaml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ac0b97142..32e84421e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,7 @@ -# This is the configuration for pre-commit, a local framework for managing pre-commit hooks -# Check out the docs at: https://pre-commit.com/ - -default_stages: [commit] +default_stages: [pre-commit] repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-builtin-literals - id: check-case-conflict @@ -17,7 +14,7 @@ repos: exclude: \.min\.js$ - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.6 + rev: v0.6.9 hooks: - id: ruff files: panel/ @@ -50,7 +47,7 @@ repos: - id: oxipng stages: [manual] - repo: https://github.com/pre-commit/mirrors-eslint - rev: v9.10.0 + rev: v9.12.0 hooks: - id: eslint args: ['-c', 'panel/.eslintrc.js', 'panel/*.ts', 'panel/models/**/*.ts', '--fix'] From 827fe64f1abc1c7d118eabd96d0e860a8b29a7d3 Mon Sep 17 00:00:00 2001 From: Christian Ibeto <152868422+chryshumble@users.noreply.github.com> Date: Tue, 8 Oct 2024 08:23:51 +0100 Subject: [PATCH 07/33] Corrected styles in doc (#7371) --- examples/reference/panes/GIF.ipynb | 2 +- examples/reference/panes/Image.ipynb | 2 +- examples/reference/panes/JPG.ipynb | 2 +- examples/reference/panes/PDF.ipynb | 2 +- examples/reference/panes/PNG.ipynb | 2 +- examples/reference/panes/SVG.ipynb | 2 +- examples/reference/panes/Str.ipynb | 2 +- examples/reference/panes/WebP.ipynb | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/reference/panes/GIF.ipynb b/examples/reference/panes/GIF.ipynb index ad2ec1a320..ba11c903d1 100644 --- a/examples/reference/panes/GIF.ipynb +++ b/examples/reference/panes/GIF.ipynb @@ -26,7 +26,7 @@ "* **``fixed_aspect``** (boolean, default=True): Whether the aspect ratio of the image should be forced to be equal.\n", "* **``link_url``** (str, default=None): A link URL to make the image clickable and link to some other website.\n", "* **``object``** (str or object): The string to display. If a non-string type is supplied the repr is displayed. \n", - "* **``style``** (dict): Dictionary specifying CSS styles\n", + "* **``styles``** (dict): Dictionary specifying CSS styles\n", "\n", "___" ] diff --git a/examples/reference/panes/Image.ipynb b/examples/reference/panes/Image.ipynb index 58090f3cf3..bcd5f62efe 100644 --- a/examples/reference/panes/Image.ipynb +++ b/examples/reference/panes/Image.ipynb @@ -37,7 +37,7 @@ "* **``fixed_aspect``** (boolean, default=True): Whether the aspect ratio of the image should be forced to be equal.\n", "* **``link_url``** (str, default=None): A link URL to make the image clickable and link to some other website.\n", "* **``object``** (str or object): The Image file to display. Can be a string pointing to a local or remote file, or an object with a ``_repr_extension_`` method, where extension is an image file extension.\n", - "* **``style``** (dict): Dictionary specifying CSS styles\n", + "* **``styles``** (dict): Dictionary specifying CSS styles\n", "\n", "___" ] diff --git a/examples/reference/panes/JPG.ipynb b/examples/reference/panes/JPG.ipynb index 2f08b9a73a..e12ee8e049 100644 --- a/examples/reference/panes/JPG.ipynb +++ b/examples/reference/panes/JPG.ipynb @@ -26,7 +26,7 @@ "* **``fixed_aspect``** (boolean, default=True): Whether the aspect ratio of the image should be forced to be equal.\n", "* **``link_url``** (str, default=None): A link URL to make the image clickable and link to some other website.\n", "* **``object``** (str or object): The JPEG file to display. Can be a string pointing to a local or remote file, or an object with a ``_repr_jpeg_`` method.\n", - "* **``style``** (dict): Dictionary specifying CSS styles\n", + "* **``styles``** (dict): Dictionary specifying CSS styles\n", "\n", "___" ] diff --git a/examples/reference/panes/PDF.ipynb b/examples/reference/panes/PDF.ipynb index 7a9306b36e..3e7a3f0c69 100644 --- a/examples/reference/panes/PDF.ipynb +++ b/examples/reference/panes/PDF.ipynb @@ -24,7 +24,7 @@ "* **``embed``** (boolean, default=False): If given a URL to a file this determines whether the image will be embedded as base64 or merely linked to.\n", "* **``object``** (str or object): The PDF file to display. Can be a string pointing to a local or remote file, or an object with a ``_repr_pdf_`` method.\n", "* **``start_page``** (int): Start page of the `.pdf` file when loading the page. \n", - "* **``style``** (dict): Dictionary specifying CSS styles.\n", + "* **``styles``** (dict): Dictionary specifying CSS styles.\n", "\n", "___" ] diff --git a/examples/reference/panes/PNG.ipynb b/examples/reference/panes/PNG.ipynb index 5769b6f1e2..121d584e61 100644 --- a/examples/reference/panes/PNG.ipynb +++ b/examples/reference/panes/PNG.ipynb @@ -26,7 +26,7 @@ "* **``fixed_aspect``** (boolean, default=True): Whether the aspect ratio of the image should be forced to be equal.\n", "* **``link_url``** (str, default=None): A link URL to make the image clickable and link to some other website.\n", "* **``object``** (str or object): The PNG file to display. Can be a string pointing to a local or remote file, or an object with a ``_repr_png_`` method.\n", - "* **``style``** (dict): Dictionary specifying CSS styles\n", + "* **``styles``** (dict): Dictionary specifying CSS styles\n", "\n", "___" ] diff --git a/examples/reference/panes/SVG.ipynb b/examples/reference/panes/SVG.ipynb index ad7e3f45a6..7f990fb28d 100644 --- a/examples/reference/panes/SVG.ipynb +++ b/examples/reference/panes/SVG.ipynb @@ -27,7 +27,7 @@ "* **``link_url``** (str, default=None): A link URL to make the image clickable and link to some other website.\n", "* **``encode``** (bool, default=True): Whether to base64 encode the SVG, when enabled SVG links may not work while disabling encoding will prevent image scaling from working.\n", "* **``object``** (str or object): The svg file to display. Can be a string pointing to a local or remote file, or an object with a ``_repr_svg_`` method.\n", - "* **``style``** (dict): Dictionary specifying CSS styles\n", + "* **``styles``** (dict): Dictionary specifying CSS styles\n", "\n", "___" ] diff --git a/examples/reference/panes/Str.ipynb b/examples/reference/panes/Str.ipynb index 62478ab457..70d68a3297 100644 --- a/examples/reference/panes/Str.ipynb +++ b/examples/reference/panes/Str.ipynb @@ -26,7 +26,7 @@ "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", "\n", "* **``object``** (str or object): The string to display. If a non-string type is supplied, the `repr` of that object is displayed. \n", - "* **``style``** (dict): Dictionary specifying CSS styles\n", + "* **``styles``** (dict): Dictionary specifying CSS styles\n", "\n", "___" ] diff --git a/examples/reference/panes/WebP.ipynb b/examples/reference/panes/WebP.ipynb index c511ae8e07..bec9e03502 100644 --- a/examples/reference/panes/WebP.ipynb +++ b/examples/reference/panes/WebP.ipynb @@ -26,7 +26,7 @@ "* **``fixed_aspect``** (boolean, default=True): Whether the aspect ratio of the image should be forced to be equal.\n", "* **``link_url``** (str, default=None): A link URL to make the image clickable and link to some other website.\n", "* **``object``** (str or object): The PNG file to display. Can be a string pointing to a local or remote file, or an object with a ``_repr_png_`` method.\n", - "* **``style``** (dict): Dictionary specifying CSS styles\n", + "* **``styles``** (dict): Dictionary specifying CSS styles\n", "\n", "___" ] From 2309bfe97b591422e8a73aeda7e7390ef6d6be3f Mon Sep 17 00:00:00 2001 From: Theom <49269671+TheoMathurin@users.noreply.github.com> Date: Tue, 8 Oct 2024 10:12:21 +0200 Subject: [PATCH 08/33] Prevent pipeline network plot from linking with other plots (#7372) --- panel/pipeline.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/panel/pipeline.py b/panel/pipeline.py index 84eacc7eab..aa475b983a 100644 --- a/panel/pipeline.py +++ b/panel/pipeline.py @@ -523,9 +523,10 @@ def tap_renderer(plot, element): yoffset=-.30, default_tools=[], axiswise=True, backend='bokeh' ) plot = (graph * labels * nodes) if self._linear else (graph * nodes) + xlim = (-0.25, depth + 0.25) plot.opts( xaxis=None, yaxis=None, min_width=400, responsive=True, - show_frame=False, height=height, xlim=(-0.25, depth+0.25), + show_frame=False, height=height, xlim=xlim, shared_axes=False, ylim=(0, 1), default_tools=['hover'], toolbar=None, backend='bokeh' ) return plot From 63f2d8d194e6bdf6d5e2a7e9bfb9e46be49d615e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 9 Oct 2024 14:13:07 +0200 Subject: [PATCH 09/33] docs: Update nested tabulator example (#7379) --- examples/reference/widgets/Tabulator.ipynb | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/examples/reference/widgets/Tabulator.ipynb b/examples/reference/widgets/Tabulator.ipynb index 3f63ca0545..1feb474bf3 100644 --- a/examples/reference/widgets/Tabulator.ipynb +++ b/examples/reference/widgets/Tabulator.ipynb @@ -282,16 +282,20 @@ "outputs": [], "source": [ "options = {\n", - " \"A\": [1, 2, 3, 4, 5],\n", - " \"B\": {\"1\": [6, 7, 8, 9, 10], \"2\": [11, 12, 13, 14, 15]},\n", + " \"A\": [\"A.1\", \"A.2\", \"A.3\", \"A.4\", \"A.5\"],\n", + " \"B\": {\n", + " \"1\": [\"B1.1\", \"B1.2\", \"B1.3\"],\n", + " \"2\": [\"B2.1\", \"B2.2\", \"B2.3\"],\n", + " \"3\": [\"B3.1\", \"B3.2\", \"B3.3\"],\n", + " },\n", "}\n", "tabulator_editors = {\n", " \"0\": {\"type\": \"list\", \"values\": [\"A\", \"B\"]},\n", - " \"1\": {\"type\": \"list\", \"values\": [1, 2]},\n", - " \"2\": {\"type\": \"nested\", \"options\": options, \"lookup_order\": [\"0\", \"1\"]},\n", + " \"1\": {\"type\": \"list\", \"values\": [1, 2, 3]},\n", + " \"Nested Selection\": {\"type\": \"nested\", \"options\": options, \"lookup_order\": [\"0\", \"1\"]},\n", "}\n", "\n", - "nested_df = pd.DataFrame({\"0\": [\"A\", \"B\"], \"1\": [1, 2], \"2\": [None, None]})\n", + "nested_df = pd.DataFrame({\"0\": [\"A\", \"B\", \"A\"], \"1\": [1, 2, 3], \"Nested Selection\": [None, None, None]})\n", "nested_table = pn.widgets.Tabulator(nested_df, editors=tabulator_editors, show_index=False)\n", "nested_table" ] From 417e325c76f724c81d8d20d20a37b9d06890b9e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:13:51 +0200 Subject: [PATCH 10/33] Bump django from 3.2.25 to 4.2.16 in /examples/apps/django_multi_apps (#7376) --- examples/apps/django_multi_apps/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/apps/django_multi_apps/requirements.txt b/examples/apps/django_multi_apps/requirements.txt index 796ea42098..4f6ab137f3 100644 --- a/examples/apps/django_multi_apps/requirements.txt +++ b/examples/apps/django_multi_apps/requirements.txt @@ -10,7 +10,7 @@ colorcet==2.0.5 constantly==15.1.0 cryptography==43.0.1 daphne==2.4.1 -Django==3.2.25 +Django==4.2.16 holoviews==1.13.2 hvplot==0.5.2 hyperlink==20.0.1 From 3260b7e235d760bae9a9071577c59bb74c369f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 9 Oct 2024 14:40:27 +0200 Subject: [PATCH 11/33] ci: Update pixi channels (#7378) --- pixi.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pixi.toml b/pixi.toml index 4dbf55e1c3..d9b80db49c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -52,9 +52,6 @@ python = "3.11.*" [feature.py312.dependencies] python = "3.12.*" -[feature.py313] -channels = ["pyviz/label/dev", "bokeh/label/rc", "conda-forge/label/python_rc", "conda-forge"] - [feature.py313.dependencies] python = "3.13.*" @@ -160,7 +157,7 @@ test-example = 'pytest -n logical --dist loadscope --nbval-lax examples' nbval = "*" [feature.test-ui] -channels = ["pyviz/label/dev", "bokeh/label/rc", "microsoft", "conda-forge"] +channels = ["microsoft"] [feature.test-ui.dependencies] playwright = { version = "*", channel = "microsoft" } From a1720644698697c2c7cd20d4abc0328b7dccbabb Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 10 Oct 2024 13:48:00 +0530 Subject: [PATCH 12/33] Fix oauth guest endpoint (#7385) --- panel/io/server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/panel/io/server.py b/panel/io/server.py index 78107b388c..53ab85ec6c 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -1021,7 +1021,8 @@ def flask_handler(slug, app): logout_endpoint=logout_endpoint, login_template=login_template, logout_template=logout_template, - error_template=oauth_error_template + error_template=oauth_error_template, + guest_endpoints=oauth_guest_endpoints, ) if oauth_key: config.oauth_key = oauth_key # type: ignore From ef5d0bef2990dfdbc0033fd5b271f14f8045d2dc Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 10 Oct 2024 13:48:33 +0530 Subject: [PATCH 13/33] Fix type hint for `oauth_guest_endpoints` (#7383) * Fix type hint for oauth_guest_endpoints * fix pre-commit --- panel/io/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/io/server.py b/panel/io/server.py index 53ab85ec6c..6165b9f4dc 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -825,7 +825,7 @@ def get_server( oauth_encryption_key: Optional[str] = None, oauth_jwt_user: Optional[str] = None, oauth_refresh_tokens: Optional[bool] = None, - oauth_guest_endpoints: Optional[bool] = None, + oauth_guest_endpoints: Optional[list[str]] = None, oauth_optional: Optional[bool] = None, login_endpoint: Optional[str] = None, logout_endpoint: Optional[str] = None, From 2986f189dc38f55407b0dc36f229e2a84dcf3274 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sun, 13 Oct 2024 11:38:29 +0200 Subject: [PATCH 14/33] fix import map typing (#7392) --- panel/custom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/custom.py b/panel/custom.py index 1e429c8dc9..e07f2e10a7 100644 --- a/panel/custom.py +++ b/panel/custom.py @@ -223,7 +223,7 @@ class CounterButton(pn.custom.ReactiveESM): # 3. Named export (`{ , ... }`): ("", ...) _exports__: ClassVar[ExportSpec] = {} - _importmap: ClassVar[dict[Literal['imports', 'scopes'], str]] = {} + _importmap: ClassVar[dict[Literal['imports', 'scopes'], dict[str,str]]] = {} __abstract = True From ae110a518a1a0da1969601874fa047b9806f1cc9 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 14 Oct 2024 17:01:58 +0200 Subject: [PATCH 15/33] Ensure that autoreload records modules to watch before startup (#7399) * Ensure that autoreload records modules to watch before startup * Apply suggestions from code review * Apply suggestions from code review * Add test * Fix test --- panel/command/serve.py | 2 +- panel/tests/command/test_serve.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/panel/command/serve.py b/panel/command/serve.py index 051ddc4007..35e9a6d16a 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -388,7 +388,7 @@ def setup_file(): if args.warm or config.autoreload: argvs = {f: args.args for f in files} applications = build_single_handler_applications(files, argvs) - initialize_session = not (args.num_procs and sys.version_info < (3, 12)) + initialize_session = not (args.num_procs != 1 and sys.version_info < (3, 12)) if config.autoreload: with record_modules(list(applications.values())): self.warm_applications( diff --git a/panel/tests/command/test_serve.py b/panel/tests/command/test_serve.py index 3626391cae..f15c451420 100644 --- a/panel/tests/command/test_serve.py +++ b/panel/tests/command/test_serve.py @@ -1,6 +1,7 @@ import os import re import tempfile +import time import pytest import requests @@ -31,6 +32,31 @@ def test_autoreload_app(py_file): assert r2.status_code == 200 assert "B" in r2.content.decode('utf-8') + +@linux_only +def test_autoreload_app_local_module(py_files): + py_file1, py_file2 = py_files + app_name = os.path.basename(py_file1.name)[:-3] + mod_name = os.path.basename(py_file2.name)[:-3] + app = f"import panel as pn; from {mod_name} import title; pn.Row('# Example').servable(title=title)" + write_file(app, py_file1.file) + write_file("title = 'A'", py_file2.file) + + with run_panel_serve(["--port", "0", '--autoreload', py_file1.name]) as p: + port = wait_for_port(p.stdout) + r = requests.get(f"http://localhost:{port}/{app_name}") + assert r.status_code == 200 + assert "A" in r.content.decode('utf-8') + + write_file("title = 'B'", py_file2.file) + py_file2.file.close() + time.sleep(1) + + r2 = requests.get(f"http://localhost:{port}/{app_name}") + assert r2.status_code == 200 + assert "B" in r2.content.decode('utf-8') + + @linux_only def test_serve_admin(py_file): app = "import panel as pn; pn.Row('# Example').servable(title='A')" From 2454da9d0ff976986ddfc3b0e01780d76dfc2987 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 15 Oct 2024 13:06:20 +0200 Subject: [PATCH 16/33] Various fixes for custom component compilation (#7381) * Various fixes for custom component compilation * Apply suggestions from code review --------- Co-authored-by: Philipp Rudiger --- panel/custom.py | 7 +++---- panel/io/compile.py | 4 ++-- panel/models/react_component.ts | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/panel/custom.py b/panel/custom.py index e07f2e10a7..2c5eea69c1 100644 --- a/panel/custom.py +++ b/panel/custom.py @@ -358,12 +358,11 @@ def _init_params(self) -> dict[str, Any]: params.pop(k) data_params[k] = v bundle_path = self._bundle_path + importmap = self._process_importmap() if bundle_path: bundle_hash = hashlib.sha256(str(bundle_path).encode('utf-8')).hexdigest() - importmap = {} else: bundle_hash = None - importmap = self._process_importmap() data_props = self._process_param_change(data_params) params.update({ 'bundle': bundle_hash, @@ -614,8 +613,8 @@ def _exports__(cls) -> ExportSpec: } if any('@mui' in v for v in imports.values()): exports.update({ - "@emotion/cache": "createCache", - "@emotion/react": ("CacheProvider",) + "@emotion/cache": ["createCache"], + "@emotion/react": [("CacheProvider",)], }) return exports diff --git a/panel/io/compile.py b/panel/io/compile.py index 70b105f29f..0df128571a 100644 --- a/panel/io/compile.py +++ b/panel/io/compile.py @@ -385,8 +385,8 @@ def compile_components( if result.stdout and out: print(f"npm output:\n{GREEN}{result.stdout}{RESET}") # noqa if result.stderr: - print("npm errors:\n{RED}{result.stderr}{RESET}") # noqa - return None + print(f"npm errors:\n{RED}{result.stderr}{RESET}") # noqa + except subprocess.CalledProcessError as e: print(f"An error occurred while running npm install:\n{RED}{e.stderr}{RESET}") # noqa return None diff --git a/panel/models/react_component.ts b/panel/models/react_component.ts index a9013e6e72..73e6646121 100644 --- a/panel/models/react_component.ts +++ b/panel/models/react_component.ts @@ -67,7 +67,7 @@ import { CacheProvider } from "@emotion/react"` render_code = ` if (rendered) { const cache = createCache({ - key: 'css-${this.model.id}', + key: 'css-${btoa(this.model.id).replace("=", "-").toLowerCase()}', prepend: true, container: view.style_cache, }) From c69b81d99c3e1cb8efd2a22e985acd95821f4854 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 15 Oct 2024 14:40:02 +0200 Subject: [PATCH 17/33] Address issues with Tabulator embed_content and optimize row expansion (#7364) --- panel/models/tabulator.py | 2 + panel/models/tabulator.ts | 97 +++++++++++++----------- panel/tests/ui/widgets/test_tabulator.py | 37 +++++++-- panel/widgets/tables.py | 29 ++++--- 4 files changed, 102 insertions(+), 63 deletions(-) diff --git a/panel/models/tabulator.py b/panel/models/tabulator.py index 22eb3a7f7f..1d37c57ab7 100644 --- a/panel/models/tabulator.py +++ b/panel/models/tabulator.py @@ -129,6 +129,8 @@ class DataTabulator(HTMLBox): editable = Bool(default=True) + embed_content = Bool(default=False) + expanded = List(Int) filename = String(default="table.csv") diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index cc48dc0681..c6221c8309 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -1,4 +1,4 @@ -import {undisplay} from "@bokehjs/core/dom" +import {display, undisplay} from "@bokehjs/core/dom" import {sum} from "@bokehjs/core/util/arrayable" import {isArray, isBoolean, isString, isNumber} from "@bokehjs/core/util/types" import {ModelEvent} from "@bokehjs/core/bokeh_events" @@ -8,7 +8,6 @@ import type * as p from "@bokehjs/core/properties" import type {LayoutDOM} from "@bokehjs/models/layouts/layout_dom" import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source" import {TableColumn} from "@bokehjs/models/widgets/tables" -import type {UIElementView} from "@bokehjs/models/ui/ui_element" import type {Attrs} from "@bokehjs/core/types" import {debounce} from "debounce" @@ -350,6 +349,7 @@ export class DataTabulatorView extends HTMLBoxView { container: HTMLDivElement | null = null _tabulator_cell_updating: boolean=false _updating_page: boolean = false + _updating_expanded: boolean = false _updating_sort: boolean = false _selection_updating: boolean = false _last_selected_row: any = null @@ -393,7 +393,7 @@ export class DataTabulatorView extends HTMLBoxView { this.tabulator.download(ftype, this.model.filename) }) - this.on_change(children, () => this.renderChildren(false)) + this.on_change(children, () => this.renderChildren()) this.on_change(expanded, () => { // The first cell is the cell of the frozen _index column. @@ -411,6 +411,12 @@ export class DataTabulatorView extends HTMLBoxView { row.cells[1].element.innerText = icon } } + // If content is embedded, views may not have been + // rendered so if expanded is updated server side + // we have to trigger a render + if (this.model.embed_content && !this._updating_expanded) { + this.renderChildren() + } }) this.on_change(cell_styles, () => { @@ -773,7 +779,7 @@ export class DataTabulatorView extends HTMLBoxView { frozenRows: (row: any) => { return (this.model.frozen_rows.length > 0) ? this.model.frozen_rows.includes(row._row.data._index) : false }, - rowFormatter: (row: any) => this._render_row(row), + rowFormatter: (row: any) => this._render_row(row, false), } if (this.model.pagination === "remote") { configuration.ajaxURL = "http://panel.pyviz.org" @@ -816,33 +822,23 @@ export class DataTabulatorView extends HTMLBoxView { return lookup } - renderChildren(all: boolean = true): void { - new Promise(async (resolve: any) => { - let new_children = await this.build_child_views() - if (all) { - new_children = this.child_views - } - resolve(new_children) - }).then((new_children) => { + renderChildren(): void { + void new Promise(async () => { + await this.build_child_views() const lookup = this.row_index - for (const index of this.model.expanded) { + const expanded = this.model.expanded + for (const index of expanded) { const model = this.get_child(index) const row = lookup.get(index) const view = model == null ? null : this._child_views.get(model) if (view != null) { - const render = (new_children as UIElementView[]).includes(view) - this._render_row(row, false, render) + this._render_row(row, index === expanded[expanded.length-1]) } } - this._update_children() - if (this.tabulator.rowManager.renderer != null) { - this.tabulator.rowManager.adjustTableSize() - } - this.invalidate_layout() }) } - _render_row(row: any, resize: boolean = true, render: boolean = true): void { + _render_row(row: any, resize: boolean = true): void { const index = row._row?.data._index if (!this.model.expanded.includes(index)) { return @@ -852,33 +848,42 @@ export class DataTabulatorView extends HTMLBoxView { if (view == null) { return } - const rowEl = row.getElement() - const style = getComputedStyle(this.tabulator.element.children[1].children[0]) - const bg = style.backgroundColor - const neg_margin = rowEl.style.paddingLeft ? `-${rowEl.style.paddingLeft}` : "0" - const prev_child = rowEl.children[rowEl.children.length-1] - let viewEl - if (prev_child != null && prev_child.className == "row-content") { - viewEl = prev_child - if (viewEl.children.length && viewEl.children[0] === view.el) { - return + schedule_when(() => { + const rowEl = row.getElement() + const style = getComputedStyle(this.tabulator.element.children[1].children[0]) + const bg = style.backgroundColor + const neg_margin = rowEl.style.paddingLeft ? `-${rowEl.style.paddingLeft}` : "0" + const prev_child = rowEl.children[rowEl.children.length-1] + let viewEl + if (prev_child != null && prev_child.className == "row-content") { + viewEl = prev_child + if (viewEl.children.length && viewEl.children[0] === view.el) { + return + } + } else { + viewEl = div({class: "row-content", style: {background_color: bg, margin_left: neg_margin, max_width: "100%", overflow_x: "hidden"}}) + rowEl.appendChild(viewEl) } - } else { - viewEl = div({class: "row-content", style: {background_color: bg, margin_left: neg_margin, max_width: "100%", overflow_x: "hidden"}}) - rowEl.appendChild(viewEl) - } - viewEl.appendChild(view.el) - if (render) { - schedule_when(() => { + display(view.el) + viewEl.appendChild(view.el) + if (view.shadow_el.children.length === 0) { view.render() view.after_render() - }, () => this.root.has_finished()) - } - if (resize) { - this._update_children() - this.tabulator.rowManager.adjustTableSize() - this.invalidate_layout() + } + if (resize) { + this._update_children() + this.resize_table() + } + }, () => this.root.has_finished()) + } + + resize_table(): void { + if (this.tabulator.rowManager.renderer != null) { + try { + this.tabulator.rowManager.adjustTableSize() + } catch (e) {} } + this.invalidate_layout() } _expand_render(cell: any): string { @@ -903,7 +908,9 @@ export class DataTabulatorView extends HTMLBoxView { } } } + this._updating_expanded = true this.model.expanded = expanded + this._updating_expanded = false if (!expanded.includes(index)) { return } @@ -1468,6 +1475,7 @@ export namespace DataTabulator { configuration: p.Property download: p.Property editable: p.Property + embed_content: p.Property expanded: p.Property filename: p.Property filters: p.Property @@ -1513,6 +1521,7 @@ export class DataTabulator extends HTMLBox { columns: [ List(Ref(TableColumn)), [] ], download: [ Bool, false ], editable: [ Bool, true ], + embed_content: [ Bool, false ], expanded: [ List(Float), [] ], filename: [ Str, "table.csv" ], filters: [ List(Any), [] ], diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 403379be83..959e9a9d24 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -1560,8 +1560,13 @@ def test_tabulator_selection_on_multi_index(page, pagination): wait_until(lambda: widget.selection == [0, 16], page) -def test_tabulator_row_content(page, df_mixed): - widget = Tabulator(df_mixed, row_content=lambda i: f"{i['str']}-row-content") +@pytest.mark.parametrize('embed_content', [False, True]) +def test_tabulator_row_content(page, df_mixed, embed_content): + widget = Tabulator( + df_mixed, + row_content=lambda i: f"{i['str']}-row-content", + embed_content=embed_content + ) serve_component(page, widget) @@ -1586,17 +1591,22 @@ def test_tabulator_row_content(page, df_mixed): closables.first.click() row_content = page.locator(f'text="{df_mixed.iloc[i]["str"]}-row-content"') - expect(row_content).to_have_count(0) + if embed_content: + expect(row_content).not_to_be_visible() + else: + expect(row_content).to_have_count(0) expected_expanded.remove(i) wait_until(lambda: widget.expanded == expected_expanded, page) -def test_tabulator_row_content_expand_from_python_init(page, df_mixed): +@pytest.mark.parametrize('embed_content', [False, True]) +def test_tabulator_row_content_expand_from_python_init(page, df_mixed, embed_content): widget = Tabulator( df_mixed, row_content=lambda i: f"{i['str']}-row-content", expanded = [0, 2], + embed_content=embed_content ) serve_component(page, widget) @@ -1614,8 +1624,13 @@ def test_tabulator_row_content_expand_from_python_init(page, df_mixed): expect(openables).to_have_count(len(df_mixed) - len(widget.expanded)) -def test_tabulator_row_content_expand_from_python_after(page, df_mixed): - widget = Tabulator(df_mixed, row_content=lambda i: f"{i['str']}-row-content") +@pytest.mark.parametrize('embed_content', [False, True]) +def test_tabulator_row_content_expand_from_python_after(page, df_mixed, embed_content): + widget = Tabulator( + df_mixed, + row_content=lambda i: f"{i['str']}-row-content", + embed_content=embed_content + ) serve_component(page, widget) @@ -1638,8 +1653,14 @@ def test_tabulator_row_content_expand_from_python_after(page, df_mixed): expect(page.locator('text="►"')).to_have_count(len(df_mixed)) -def test_tabulator_row_content_expand_after_filtered(page, df_mixed): - table = Tabulator(df_mixed, row_content=lambda e: f"Hello {e.int}", header_filters=True) +@pytest.mark.parametrize('embed_content', [False, True]) +def test_tabulator_row_content_expand_after_filtered(page, df_mixed, embed_content): + table = Tabulator( + df_mixed, + row_content=lambda e: f"Hello {e.int}", + header_filters=True, + embed_content=embed_content + ) serve_component(page, table) diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index 4f1f5748f9..2ac704603b 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -1228,8 +1228,8 @@ class Tabulator(BaseTable): _rename: ClassVar[Mapping[str, str | None]] = { 'selection': None, 'row_content': None, 'row_height': None, - 'text_align': None, 'embed_content': None, 'header_align': None, - 'header_filters': None, 'header_tooltips': None, 'styles': 'cell_styles', + 'text_align': None, 'header_align': None, 'header_filters': None, + 'header_tooltips': None, 'styles': 'cell_styles', 'title_formatters': None, 'sortable': None, 'initial_page_size': None } @@ -1536,10 +1536,14 @@ def _get_children(self): start = (self.page-1)*nrows df = df.iloc[start:(start+nrows)] indexed_children, children = {}, {} - expanded = [] if self.embed_content: - for i in range(len(df)): - expanded.append(i) + indexes = list(range(len(df))) + mapped = self._map_indexes(indexes) + expanded = [ + i for i, m in zip(indexes, mapped) + if m in self.expanded + ] + for i in indexes: idx = df.index[i] if idx in self._indexed_children: child = self._indexed_children[idx] @@ -1547,6 +1551,7 @@ def _get_children(self): child = panel(self.row_content(df.iloc[i])) indexed_children[idx] = children[i] = child else: + expanded = [] for i in self.expanded: idx = self.value.index[i] if idx in self._indexed_children: @@ -1579,7 +1584,8 @@ def _get_model_children(self, doc, root, parent, comm=None): return models def _update_children(self, *events): - if all(e.name in ('page', 'page_size', 'pagination', 'sorters') for e in events) and self.pagination != 'remote': + page_event = all(e.name in ('page', 'page_size', 'pagination', 'sorters') for e in events) + if (page_event and self.pagination != 'remote'): return for event in events: if event.name == 'value' and self._indexes_changed(event.old, event.new): @@ -1590,11 +1596,12 @@ def _update_children(self, *events): self._indexed_children.clear() self._child_panels, removed, expanded = self._get_children() for ref, (m, _) in self._models.copy().items(): - root, doc, comm = state._views[ref][1:] - for child_panel in removed: - child_panel._cleanup(root) - children = self._get_model_children(doc, root, m, comm) - msg = {'expanded': expanded, 'children': children} + msg = {'expanded': expanded} + if not self.embed_content or any(e.name == 'row_content' for e in events): + root, doc, comm = state._views[ref][1:] + for child_panel in removed: + child_panel._cleanup(root) + msg['children'] = self._get_model_children(doc, root, m, comm) self._apply_update([], msg, m, ref) @updating From 8bb20b839660cc3b96e449e7973b9e5e8e98285f Mon Sep 17 00:00:00 2001 From: Coderambling <159031875+Coderambling@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:53:09 +0200 Subject: [PATCH 18/33] Update components_overview.md with text fixes (#7335) --- .../components/components_overview.md | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/doc/explanation/components/components_overview.md b/doc/explanation/components/components_overview.md index 46b6aa24f0..1a78bffca2 100644 --- a/doc/explanation/components/components_overview.md +++ b/doc/explanation/components/components_overview.md @@ -11,16 +11,16 @@ pn.extension(notifications=True) The main objects that Panel provides, and that we are going to call *components* hereafter, short for *visual components*, include: - *Widgets*: widgets are components, usually quite small even if there are exceptions, that allow your users to interact with your app. Most importantly, they allow you to get user input! Examples include a text input, a checkbox, a slider, etc. -- *Panes*: panes are wrappers around some data that allow you to render that data, possibly customizing the rendering. Panel is known to support many date types, especially from the PyData ecosystem. You can indeed display a Pandas DataFrame, a Plotly plot, a Matplotlib plot, an Altair plot, all together on the same app! You can of course display HTML text or just raw text. Panes aren't limited to rendering data statically, they can allow for some user interactions and state syncing, like for instance the `Audio` or `Vega` panes. -- *Indicators*: indicators are useful to display some static state, they are indeed implemented as widgets that you can only control programmatically. You'll find for instance a progress indicator or a tooltip. +- *Panes*: panes are wrappers around some data that allow you to render that data, possibly customizing the rendering. Panel is known to support many data types, especially from the PyData ecosystem. You can indeed display a Pandas DataFrame, a Plotly plot, a Matplotlib plot, an Altair plot, all together on the same app! You can of course display HTML text or just raw text. Panes aren't limited to rendering data statically, they can allow for some user interactions and state syncing, like for instance the `Audio` or `Vega` panes. +- *Indicators*: indicators are useful to display some static state. They are implemented as widgets that you can only control programmatically. Examples include a progress indicator or a tooltip. - *Layouts*: after having built various widgets, panes and indicators, it's time to display them together. Panel provides a dozen of layout components, including of course the most common `Row` and `Column` layouts. -- *Templates*: templates are components that render multiple Panel objects in an HTML document. The basic template, which you get when you serve an app without setting any template, is basically a blank canvas. Instead when you use one of the built-in templates you can easily improve the design and branding of your app, which will get for free a header, a sidebar, etc. +- *Templates*: templates are components that render multiple Panel objects in an HTML document. The basic template, which you get when you serve an app without setting any template, is basically a blank canvas. When you use one of the built-in templates instead, you can easily improve the design and branding of your app, as the templates include elements like a header, a sidebar, etc. - *Notifications*: notifications are components that display so called "toasts", designed to mimic the push notifications that have been popularized by mobile and desktop operating systems. -All the Panel components can be visualized on the [Component Gallery](../../reference/index). +All the Panel components can be found in the [Component Gallery](../../reference/index). :::{tip} -Components usually have in their docstring a link to their documentation page, use `?` in a notebook or your IDE inspection capabilities to access the link. +Components usually have a link to their documentation page in their docstring, use `?` in a notebook or your IDE inspection capabilities to access the link. ::: ## Parameterized components @@ -31,7 +31,7 @@ All components in Panel are built on the [Param](https://param.holoviz.org/) lib pn.widgets.IntRangeSlider.width = 350 ``` -or on each instance: +and on each instance: ```{pyodide} pn.widgets.IntRangeSlider(width=100) @@ -60,9 +60,9 @@ w_text.width = 100 w_text.value = 'new text' ``` -What you just experimented is one-way syncing from your code to the user interface, i.e. from your running Python interpreter to your browser tab. +What you just experienced is one-way syncing from your code to the user interface, i.e. from your running Python interpreter to your browser tab. -This can also work the other way around, i.e. when you modify a component from the user interface directly its Python state gets updated accordingly. Try this out by typing some text in one of the widgets above and then execute the cell below. You will see that what you typed is now reflected in the widget `value`. In this case we say that the `value` *Parameter* is bi-directionally synced (or linked). +This can also work the other way around, i.e. when you modify a component from the user interface directly, its Python state gets updated accordingly. Try this out by typing some text in one of the widgets above and then execute the cell below. You will see that what you typed is now reflected in the widget `value`. In this case we say that the `value` *Parameter* is bi-directionally synced (or linked). ```{pyodide} w_text.value @@ -80,7 +80,7 @@ Widgets **all** have a `value` *Parameter* that holds the widget state and that :::{note} -One gotcha that doesn't only apply to the `value` *Parameter* but that you are more likely to encounter with this *Parameter* than others is when it is referencing a mutable data structure that you mutate inplace. Take for example a `MultiSelect` widget whose `value` is a `list`. If you programmatically update that list directly, with for example `append` or `extend`, Panel will not be able to detect that change. In which case you need to explicitly trigger updates with `w_multiselect.param.trigger('value')` that will run all the same underlying machinery as if you were setting the *Parameter* to a new value. A notable widget that holds a multable datastructure is the `Tabulator` widget whose `value` is a Pandas DataFrame that can be updated inplace with e.g. `df.loc[0, 'A'] = new_value`, its `patch` method allows to both update the data and the user interface. +One gotcha that doesn't only apply to the `value` *Parameter* but that you are more likely to encounter with this *Parameter* than others is when it is referencing a mutable data structure that you mutate in-place. Take for example a `MultiSelect` widget whose `value` is a `list`. If you programmatically update that list directly, with for example `append` or `extend`, Panel will not be able to detect that change. In which case you need to explicitly trigger updates with `w_multiselect.param.trigger('value')` that will run all the same underlying machinery as if you were setting the *Parameter* to a new value. A notable widget that holds a multable datastructure is the `Tabulator` widget whose `value` is a Pandas DataFrame that can be updated in-place with e.g. `df.loc[0, 'A'] = new_value`, its `patch` method allows to both update the data and the user interface. ::: ```{pyodide} @@ -276,7 +276,7 @@ tabs.insert(0, ('Slider', pn.widgets.FloatSlider())) #### Grid-like API -Grid-like layouts are initialized empty and populated setting 2D assignments to specify the index or span on indices the object in the grid should occupy. Just like a Python array, the indexing is zero-based and specifies the rows first and the columns second, i.e. `gridlike[0, 1]` would assign an object to the first row and second column. +Grid-like layouts are initialized empty and populated by setting 2D assignments to specify the index or span on indices the object in the grid should occupy. Just like a Python array, the indexing is zero-based and specifies the rows first and the columns second, i.e. `gridlike[0, 1]` would assign an object to the first row and second column. To demonstrate the abilities, let us declare a grid with a wide range of different objects, including `Spacers`, HoloViews objects, images, and widgets. @@ -340,7 +340,7 @@ w_text.visible = True #### Style -A few *Parameters* allow to control the style of components, including `styles`, `stylesheets`, `css_classes` and `design`. These will be explored in more details in one of the next guides. As a teaser, the next cell is a simple example leveraging the `styles` *Parameter* only, that accepts a dictionary of CSS styles. +A few *Parameters* allow to control the style of components, including `styles`, `stylesheets`, `css_classes` and `design`. These will be explored in more detail in one of the next guides. As a teaser, the next cell is a simple example leveraging the `styles` *Parameter* only, that accepts a dictionary of CSS styles. ```{pyodide} custom_style = { @@ -355,11 +355,11 @@ pn.widgets.FloatSlider(name='Number', styles=custom_style) #### Size and responsivity -A few *Parameters* allow to control the size and responsivity of components, including `height`, `width`, `min_height`, `min_width` and `sizing_mode`. These will be explored in more details in one of the next guides. +A few *Parameters* allow to control the size and responsivity of components, including `height`, `width`, `min_height`, `min_width` and `sizing_mode`. These will be explored in more detail in one of the next guides. #### `margin` -The `margin` *Parameter* can be used to create space around an element defined as the number of pixels at the (top, right, bottom, and left). When you set it with a single value the margin is going to be applied to each side of the element, `margin` allows for more fine-grained distributio of the margin. +The `margin` *Parameter* can be used to create space around an element defined as the number of pixels at the (top, right, bottom, and left). When you set it with a single value, the margin is going to be applied to each side of the element. `margin` allows for more fine-grained distributio of the margin. ```{pyodide} pn.widgets.Button(name='Click', margin=(25, 0, 0, 0)) @@ -377,12 +377,11 @@ pn.Row( styles={'background': 'lightgrey'}, ) ``` - ## Templates -A template is the HTML document that ends up being served by your app, it defines what resources (Javascript, CSS) need to be loaded, the page title, where the Panel objects are supposed to be rendered on the page, etc. +A template is the HTML document that ends up being served by your app. It defines what resources (Javascript, CSS) need to be loaded, the page title, where the Panel objects are supposed to be rendered on the page, etc. -When you serve an app without defining a particular template Panel serves it with its default template, that is pretty much a blank canvas where the served objects, if there are a few of them, will be rendered vertically one after the other. +When you serve an app without defining a particular template Panel serves it with its default template, which is pretty much a blank canvas where the served objects, if there are a few of them, will be rendered vertically one after the other. Try saving the following snippet in a `app.py` file and serving it with `panel serve app.py --show` @@ -394,7 +393,7 @@ pn.panel('Some text').servable() pn.panel('More text').servable() ``` -When developing an app, someone (possibly you!) will require at some point to make it prettier! A quick way to achieve that is to wrap your app in one of the templates that Panel provides, that are defined by declaring four main content areas on the page, which can be populated as desired: +When developing an app, someone (possibly you!) will be required at some point to make it prettier! A quick way to achieve that is to wrap your app in one of the templates that Panel provides, that are defined by declaring four main content areas on the page, which can be populated as desired: - `header`: The header area of the HTML page - `sidebar`: A collapsible sidebar @@ -429,7 +428,6 @@ template.sidebar.append(w_number) template.main.append(p_hearts) template.show() ``` - ## Notifications The web apps you end up building with Panel are often quite interactive. Therefore you will be interested in finding a way to let your users know what's going on, when their operations succeed or abort, etc. This is exactly what notifications are for! Contrary to the components we have just covered, notifications are objects you don't manipulate directly, instead you just call `pn.state.notifications` with one the following methods: `success`, `info`, `warning` and `error`. From 2b981da0abf11e3273c3d6db5cbb6621980d383c Mon Sep 17 00:00:00 2001 From: Andrew <15331990+ahuang11@users.noreply.github.com> Date: Tue, 15 Oct 2024 15:25:58 -0700 Subject: [PATCH 19/33] Prevent tabulator from overlapping when max_height is set (#7403) * limit tabulator height with max height * use maxHeight config * reverse logic --- panel/models/tabulator.ts | 3 +++ panel/tests/ui/widgets/test_tabulator.py | 26 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index c6221c8309..3cc745b2a3 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -781,6 +781,9 @@ export class DataTabulatorView extends HTMLBoxView { }, rowFormatter: (row: any) => this._render_row(row, false), } + if (this.model.max_height != null) { + configuration.maxHeight = this.model.max_height + } if (this.model.pagination === "remote") { configuration.ajaxURL = "http://panel.pyviz.org" configuration.sortMode = "remote" diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 959e9a9d24..8663a597f2 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -1159,6 +1159,32 @@ def test_tabulator_patch_no_height_resize(page): wait_until(lambda: page.locator('.pnx-tabulator').evaluate(at_bottom_script), page) +def test_tabulator_max_height_set(page): + df = pd.DataFrame({'col': np.random.random(100)}) + widget = Tabulator(df, max_height=200) + + serve_component(page, widget) + + table = page.locator('.pnx-tabulator') + expect(table).to_have_css('max-height', '200px') + assert table.bounding_box()['height'] <= 200 + + +def test_tabulator_max_height_unset(page): + """ + If max_height is not set, Tabulator should not set it to null; + else there's some recursion issues in the console and lag + """ + df = pd.DataFrame({'col': np.random.random(100)}) + widget = Tabulator(df) + + serve_component(page, widget) + + table = page.locator('.pnx-tabulator') + expect(table).to_have_css('max-height', 'none') + assert table.bounding_box()['height'] >= 200 + + @pytest.mark.parametrize( 'pagination', ('local', 'remote', None) ) From e01e53eb3fbe58fe5c99b9bc7bca948fcbb04451 Mon Sep 17 00:00:00 2001 From: Nick Chen Date: Wed, 16 Oct 2024 07:52:45 -0600 Subject: [PATCH 20/33] docs: fix typo in panel.io.server docstring (#7405) Replaced `panel` with `panels` --- panel/io/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/io/server.py b/panel/io/server.py index 6165b9f4dc..1aadb42285 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -703,7 +703,7 @@ def serve( Arguments --------- - panel: Viewable, function or {str: Viewable or function} + panels: Viewable, function or {str: Viewable or function} A Panel object, a function returning a Panel object or a dictionary mapping from the URL slug to either. port: int (optional, default=0) From 0f3f98d778df1c41dce411c625880832f84db3c0 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 16 Oct 2024 10:11:48 -0400 Subject: [PATCH 21/33] Sync dark mode on browser_info object (#7382) --- panel/models/browser.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/panel/models/browser.ts b/panel/models/browser.ts index 8aa69ccb81..1d2b1e4b7b 100644 --- a/panel/models/browser.ts +++ b/panel/models/browser.ts @@ -9,7 +9,11 @@ export class BrowserInfoView extends View { super.initialize() if (window.matchMedia != null) { - this.model.dark_mode = window.matchMedia("(prefers-color-scheme: dark)").matches + const darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + darkModeMediaQuery.addEventListener("change", (e) => { + this.model.dark_mode = e.matches + }) + this.model.dark_mode = darkModeMediaQuery.matches } this.model.device_pixel_ratio = window.devicePixelRatio if (navigator != null) { From 6c3a9cea284797bd44aae35566c7ded7a3ef10c4 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 17 Oct 2024 07:00:48 -0400 Subject: [PATCH 22/33] Allow loading ESM bundles from URL (#7410) --- doc/how_to/custom_components/esm/build.md | 55 +++++++++++++++++++++++ panel/custom.py | 27 ++++++++--- panel/io/server.py | 2 +- panel/models/react_component.ts | 13 +++--- panel/models/reactive_esm.ts | 21 ++++++--- 5 files changed, 98 insertions(+), 20 deletions(-) diff --git a/doc/how_to/custom_components/esm/build.md b/doc/how_to/custom_components/esm/build.md index 92f85148e0..c2f888c150 100644 --- a/doc/how_to/custom_components/esm/build.md +++ b/doc/how_to/custom_components/esm/build.md @@ -252,6 +252,7 @@ panel compile my_package.my_module my_package.subpackage.other_module you will end up with a single `custom.bundle.js` file placed in the `my_package/dist` directory. +(build-dir)= #### Using the `--build-dir` Option The `--build-dir` option allows you to specify a custom directory where the `package.json` and raw JavaScript/JSX modules will be written. This is useful if you need to manually modify the dependencies before the bundling process and/or debug issues while bundling. To use this feature, follow these steps: @@ -348,6 +349,8 @@ export function render() { :::: +#### Build + Once you have set up these three files you have to install the packages with `npm`: ```bash @@ -361,3 +364,55 @@ esbuild confetti.js --bundle --format=esm --minify --outfile=ConfettiButton.bund ``` This will create a new file called `ConfettiButton.bundle.js`, which includes all the dependencies (even CSS, image files and other static assets if you have imported them). + + +#### Complex Bundles + +If you want to bundle multiple components into a singular bundle and do not want to leverage the built-in compilation you can make do without specifying the `_esm` class variable entirely and always load the bundle directly. If you organize your Javascript/TypeScript/React code in the same way as described in the [--build-dir](#build-dir) section you can have a manual compilation workflow with all the benefits of automatic reload. + +As an example let's say you have a module with multiple components: + +``` +panel_custom/ +├── build/ + ├── index.js + ├── package.json + ├── .js + └── .js +├── __init__.py +├── components.py +``` + +Ensure that the `index.js` file exports each component: + +::::{tab-set} + +:::{tab-item} `JSComponent` +```javascript +import * as Component from "./Component" +import * as OtherComponent from "./OtherComponent" +export default {Component, OtherComponent} +``` +::: + +:::{tab-item} `ReactComponent` +A `ReactComponent` library MUST also export `React` and `createRoot`: + +```javascript +import * as Component from "./Component" +import * as OtherComponent from "./OtherComponent" +import * as React from "react" +import {createRoot} from "react-dom/client" +export default {Component, OtherComponent, React, createRoot} +``` +::: + +:::: + +You can now develop your JS components as if it were a normal JS library. During the build step you would then run: + +```bash +esbuild panel-custom/build/index.js --bundle --format=esm --minify --outfile=panel_custom/panel_custom.components.bundle.js +``` + +or replace `panel_custom.components.bundle.js` with the path specified on your component's `_bundle` attribute. diff --git a/panel/custom.py b/panel/custom.py index 2c5eea69c1..cbec8d288a 100644 --- a/panel/custom.py +++ b/panel/custom.py @@ -20,6 +20,7 @@ from .config import config from .io.datamodel import construct_data_model +from .io.resources import component_resource_path from .io.state import state from .models import ( AnyWidgetComponent as _BkAnyWidgetComponent, @@ -235,7 +236,7 @@ def __init__(self, **params): @classproperty def _bundle_path(cls) -> os.PathLike | None: - if config.autoreload: + if config.autoreload and cls._esm: return try: mod_path = pathlib.Path(inspect.getfile(cls)).parent @@ -272,7 +273,7 @@ def _bundle_path(cls) -> os.PathLike | None: @classmethod def _esm_path(cls, compiled: bool = True) -> os.PathLike | None: - if compiled: + if compiled or not cls._esm: bundle_path = cls._bundle_path if bundle_path: return bundle_path @@ -295,8 +296,21 @@ def _esm_path(cls, compiled: bool = True) -> os.PathLike | None: @classmethod def _render_esm(cls, compiled: bool | Literal['compiling'] = True): - if (esm_path:= cls._esm_path(compiled=compiled is True)): - esm = esm_path.read_text(encoding='utf-8') + esm_path = cls._esm_path(compiled=compiled is True) + if esm_path: + if esm_path == cls._bundle_path and cls.__module__ in sys.modules: + base_cls = cls + for scls in cls.__mro__[1:][::-1]: + if not issubclass(scls, ReactiveESM): + continue + if esm_path == scls._esm_path(compiled=compiled is True): + base_cls = scls + esm = component_resource_path(base_cls, '_bundle_path', esm_path) + if config.autoreload: + modified = hashlib.sha256(str(esm_path.stat().st_mtime).encode('utf-8')).hexdigest() + esm += f'?{modified}' + else: + esm = esm_path.read_text(encoding='utf-8') else: esm = cls._esm esm = textwrap.dedent(esm) @@ -360,7 +374,10 @@ def _init_params(self) -> dict[str, Any]: bundle_path = self._bundle_path importmap = self._process_importmap() if bundle_path: - bundle_hash = hashlib.sha256(str(bundle_path).encode('utf-8')).hexdigest() + if bundle_path == self._esm_path(not config.autoreload) and cls.__module__ in sys.modules: + bundle_hash = 'url' + else: + bundle_hash = hashlib.sha256(str(bundle_path).encode('utf-8')).hexdigest() else: bundle_hash = None data_props = self._process_param_change(data_params) diff --git a/panel/io/server.py b/panel/io/server.py index 1aadb42285..153a8c76cb 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -596,7 +596,7 @@ class ComponentResourceHandler(StaticFileHandler): _resource_attrs = [ '__css__', '__javascript__', '__js_module__', '__javascript_modules__', '_resources', - '_css', '_js', 'base_css', 'css', '_stylesheets', 'modifiers' + '_css', '_js', 'base_css', 'css', '_stylesheets', 'modifiers', '_bundle_path' ] def initialize(self, path: Optional[str] = None, default_filename: Optional[str] = None): diff --git a/panel/models/react_component.ts b/panel/models/react_component.ts index 73e6646121..c6a6219892 100644 --- a/panel/models/react_component.ts +++ b/panel/models/react_component.ts @@ -34,7 +34,7 @@ export class ReactComponentView extends ReactiveESMView { protected override _render_code(): string { let render_code = ` -if (rendered && view.model.usesReact) { +if (rendered) { view._changing = true const root = createRoot(view.container) try { @@ -44,9 +44,10 @@ if (rendered && view.model.usesReact) { } }` let import_code + const cache_key = (this.model.bundle === "url") ? this.model.esm : (this.model.bundle || `${this.model.class_name}-${this.model.esm.length}`) if (this.model.bundle) { import_code = ` -const ns = await view._module_cache.get(view.model.bundle) +const ns = await view._module_cache.get("${cache_key}") const {React, createRoot} = ns.default` } else { import_code = ` @@ -56,7 +57,7 @@ import { createRoot } from "react-dom/client"` if (this.model.usesMui) { if (this.model.bundle) { import_code = ` -const ns = await view._module_cache.get(view.model.bundle) +const ns = await view._module_cache.get("${cache_key}") const {CacheProvider, React, createCache, createRoot} = ns.default` } else { import_code = ` @@ -67,7 +68,7 @@ import { CacheProvider } from "@emotion/react"` render_code = ` if (rendered) { const cache = createCache({ - key: 'css-${btoa(this.model.id).replace("=", "-").toLowerCase()}', + key: 'css-${this.model.id.replace("-", "").replace(/\d/g, (digit) => String.fromCharCode(digit.charCodeAt(0) + 49)).toLowerCase()}', prepend: true, container: view.style_cache, }) @@ -248,10 +249,6 @@ export class ReactComponent extends ReactiveESM { return false } - get usesReact(): boolean { - return this.compiled !== null && this.compiled.includes("React") - } - override compile(): string | null { const compiled = super.compile() if (this.bundle) { diff --git a/panel/models/reactive_esm.ts b/panel/models/reactive_esm.ts index b0a6698007..2e8a47bd69 100644 --- a/panel/models/reactive_esm.ts +++ b/panel/models/reactive_esm.ts @@ -630,17 +630,26 @@ export class ReactiveESM extends HTMLBox { this.compiled = compiled this._declare_importmap() let esm_module - const cache_key = this.bundle || `${this.class_name}-${this.esm.length}` + const use_cache = (!this.dev || this.bundle) + const cache_key = (this.bundle === "url") ? this.esm : (this.bundle || `${this.class_name}-${this.esm.length}`) let resolve: (value: any) => void - if (!this.dev && MODULE_CACHE.has(cache_key)) { + if (use_cache && MODULE_CACHE.has(cache_key)) { esm_module = Promise.resolve(MODULE_CACHE.get(cache_key)) } else { - if (!this.dev) { + if (use_cache) { MODULE_CACHE.set(cache_key, new Promise((res) => { resolve = res })) } - const url = URL.createObjectURL( - new Blob([this.compiled], {type: "text/javascript"}), - ) + let url + if (this.bundle === "url") { + const parts = location.pathname.split("/") + let path = parts.slice(0, parts.length-1).join("/") + if (path.length) { + path += "/" + } + url = `${location.origin}/${path}${this.esm}` + } else { + url = URL.createObjectURL(new Blob([this.compiled], {type: "text/javascript"})) + } esm_module = (window as any).importShim(url) } this.compiled_module = (esm_module as Promise).then((mod: any) => { From 4fbf3612d8fd0d4b55fdd3881d07d5a18bc00d95 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 17 Oct 2024 08:38:20 -0400 Subject: [PATCH 23/33] Only attempt loading ESM bundles from URL when running server (#7412) * Only attempt loading ESM bundles from URL when running server * Handle pyodide --- panel/custom.py | 47 +++++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/panel/custom.py b/panel/custom.py index cbec8d288a..f7d858ee67 100644 --- a/panel/custom.py +++ b/panel/custom.py @@ -295,10 +295,10 @@ def _esm_path(cls, compiled: bool = True) -> os.PathLike | None: return @classmethod - def _render_esm(cls, compiled: bool | Literal['compiling'] = True): + def _render_esm(cls, compiled: bool | Literal['compiling'] = True, server: bool = False): esm_path = cls._esm_path(compiled=compiled is True) if esm_path: - if esm_path == cls._bundle_path and cls.__module__ in sys.modules: + if esm_path == cls._bundle_path and cls.__module__ in sys.modules and server: base_cls = cls for scls in cls.__mro__[1:][::-1]: if not issubclass(scls, ReactiveESM): @@ -343,8 +343,12 @@ async def _watch_esm(self): self._update_esm() def _update_esm(self): - esm = self._render_esm(not config.autoreload) for ref, (model, _) in self._models.copy().items(): + if ref not in state._views: + continue + doc = state._views[ref][2] + is_session = doc.session_context and doc.session_context.server_context + esm = self._render_esm(not config.autoreload, server=is_session) if esm == model.esm: continue self._apply_update({}, {'esm': esm}, model, ref) @@ -353,44 +357,43 @@ def _update_esm(self): def _linked_properties(self) -> list[str]: return [p for p in self._data_model.properties() if p not in ('js_property_callbacks',)] - def _init_params(self) -> dict[str, Any]: + def _get_properties(self, doc: Document) -> dict[str, Any]: + props = super()._get_properties(doc) cls = type(self) - ignored = [p for p in Reactive.param if not issubclass(cls.param[p].owner, ReactiveESM)] - params = { - p : getattr(self, p) for p in list(Layoutable.param) - if getattr(self, p) is not None and p != 'name' - } data_params = {} + ignored = [p for p in Reactive.param if not issubclass(cls.param[p].owner, ReactiveESM)] for k, v in self.param.values().items(): - if ( - (k in ignored and k != 'name') or - (((p:= self.param[k]).precedence or 0) < 0) or - is_viewable_param(p) - ): + p = self.param[k] + is_viewable = is_viewable_param(p) + if (k in ignored and k != 'name') or ((p.precedence or 0) < 0) or is_viewable: + if is_viewable and k in props: + props.pop(k) continue - if k in params: - params.pop(k) + if k in props: + props.pop(k) data_params[k] = v bundle_path = self._bundle_path importmap = self._process_importmap() + is_session = False if bundle_path: - if bundle_path == self._esm_path(not config.autoreload) and cls.__module__ in sys.modules: + is_session = (doc.session_context and doc.session_context.server_context) + if bundle_path == self._esm_path(not config.autoreload) and cls.__module__ in sys.modules and is_session: bundle_hash = 'url' else: bundle_hash = hashlib.sha256(str(bundle_path).encode('utf-8')).hexdigest() else: bundle_hash = None data_props = self._process_param_change(data_params) - params.update({ + props.update({ 'bundle': bundle_hash, 'class_name': camel_to_kebab(cls.__name__), 'data': self._data_model(**{p: v for p, v in data_props.items() if p not in ignored}), 'dev': config.autoreload or getattr(self, '_debug', False), - 'esm': self._render_esm(not config.autoreload), + 'esm': self._render_esm(not config.autoreload, server=is_session), 'importmap': importmap, 'name': cls.__name__ }) - return params + return props @classmethod def _process_importmap(cls): @@ -636,8 +639,8 @@ def _exports__(cls) -> ExportSpec: return exports @classmethod - def _render_esm(cls, compiled: bool | Literal['compiling'] = True): - esm = super()._render_esm(compiled=compiled) + def _render_esm(cls, compiled: bool | Literal['compiling'] = True, server: bool = False): + esm = super()._render_esm(compiled=compiled, server=server) if compiled == 'compiling': esm = 'import * as React from "react"\n' + esm return esm From 01529d6af52b075cad3c109aff2569015c17e651 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 17 Oct 2024 08:39:14 -0400 Subject: [PATCH 24/33] Add CHANGELOG for 1.5.3 (#7409) * Add CHANGELOG for 1.5.3 * Update CHANGELOG --- CHANGELOG.md | 25 +++++++++++++++++++++++++ doc/about/releases.md | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9562df7ac3..8374fd560c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Releases +## Version 1.5.3 + +This release fixes a number of smaller regressions related to `Tabulator` `row_content`, ensures `--dev`/`--autoreload` picks up on external modules correctly and resolves OAuth guest endpoints correctly. Additionally it introduces some enhancements and bug fixes for custom components, such as adding support for loading custom components ESM Javascript bundles from the inbuilt endpoint ensuring that the bundle can be cached by the browser. Many thanks and welcome to our new contributors @chryshumble and @haojungc, our returning contributors @TheoMathurin, @aktech and @Coderambling and the core maintainer team @Hoxbro, @ahuang11, @MarcSkovMadsen and @philippjfr for their contributions to this release. + +### Enhancements + +- Sync dark mode on `browser_info` object ([#7382](https://github.com/holoviz/panel/pull/7382)) +- Allow loading custom component ESM bundles from URL ([#7410](https://github.com/holoviz/panel/pull/7410)) + +### Bug fixes + +- Address issues with `Tabulator` embed_content and optimize row expansion ([#7364](https://github.com/holoviz/panel/pull/7364)) +- Prevent pipeline network plot from linking with other plots ([#7372](https://github.com/holoviz/panel/pull/7372)) +- Various fixes for custom component compilation ([#7381](https://github.com/holoviz/panel/pull/7381)) +- Fix OAuth guest endpoint ([#7385](https://github.com/holoviz/panel/pull/7385)) +- Fix `ReactiveESM._importmap` typing ([#7392](https://github.com/holoviz/panel/pull/7392)) +- Ensure that autoreload records modules to watch before startup ([#7399](https://github.com/holoviz/panel/pull/7399)) +- Prevent `Tabulator` from overlapping when `max_height` is set ([#7403](https://github.com/holoviz/panel/pull/7403)) + +### Documentation + +- Update components_overview.md with text fixes ([#7335](https://github.com/holoviz/panel/pull/7335)) +- Corrected styles in doc ([#7371](https://github.com/holoviz/panel/pull/7371)) +- Fix typo in `panel.io.server` docstring ([#7405](https://github.com/holoviz/panel/pull/7405)) + ## Version 1.5.2 Date: 2024-10-03 diff --git a/doc/about/releases.md b/doc/about/releases.md index 8ca3667cf5..b6050cec82 100644 --- a/doc/about/releases.md +++ b/doc/about/releases.md @@ -2,6 +2,31 @@ See [the HoloViz blog](https://blog.holoviz.org/#category=panel) for a visual summary of the major features added in each release. +## Version 1.5.3 + +This release fixes a number of smaller regressions related to `Tabulator` `row_content`, ensures `--dev`/`--autoreload` picks up on external modules correctly and resolves OAuth guest endpoints correctly. Additionally it introduces some enhancements and bug fixes for custom components, such as adding support for loading custom components ESM Javascript bundles from the inbuilt endpoint ensuring that the bundle can be cached by the browser. Many thanks and welcome to our new contributors @chryshumble and @haojungc, our returning contributors @TheoMathurin, @aktech and @Coderambling and the core maintainer team @Hoxbro, @ahuang11, @MarcSkovMadsen and @philippjfr for their contributions to this release. + +### Enhancements + +- Sync dark mode on `browser_info` object ([#7382](https://github.com/holoviz/panel/pull/7382)) +- Allow loading custom component ESM bundles from URL ([#7410](https://github.com/holoviz/panel/pull/7410)) + +### Bug fixes + +- Address issues with `Tabulator` embed_content and optimize row expansion ([#7364](https://github.com/holoviz/panel/pull/7364)) +- Prevent pipeline network plot from linking with other plots ([#7372](https://github.com/holoviz/panel/pull/7372)) +- Various fixes for custom component compilation ([#7381](https://github.com/holoviz/panel/pull/7381)) +- Fix OAuth guest endpoint ([#7385](https://github.com/holoviz/panel/pull/7385)) +- Fix `ReactiveESM._importmap` typing ([#7392](https://github.com/holoviz/panel/pull/7392)) +- Ensure that autoreload records modules to watch before startup ([#7399](https://github.com/holoviz/panel/pull/7399)) +- Prevent `Tabulator` from overlapping when `max_height` is set ([#7403](https://github.com/holoviz/panel/pull/7403)) + +### Documentation + +- Update components_overview.md with text fixes ([#7335](https://github.com/holoviz/panel/pull/7335)) +- Corrected styles in doc ([#7371](https://github.com/holoviz/panel/pull/7371)) +- Fix typo in `panel.io.server` docstring ([#7405](https://github.com/holoviz/panel/pull/7405)) + ## Version 1.5.2 Date: 2024-10-03 From 7cb5436f3a82400dfa48f88ba14edbd38835db98 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 17 Oct 2024 14:39:51 +0200 Subject: [PATCH 25/33] Bump panel.js version to 1.5.3-a.1 --- panel/package-lock.json | 4 ++-- panel/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/package-lock.json b/panel/package-lock.json index 2c28446633..e0c750f4cf 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -1,12 +1,12 @@ { "name": "@holoviz/panel", - "version": "1.5.2", + "version": "1.5.3-a.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@holoviz/panel", - "version": "1.5.2", + "version": "1.5.3-a.1", "license": "BSD-3-Clause", "dependencies": { "@bokeh/bokehjs": "3.6.0", diff --git a/panel/package.json b/panel/package.json index d03fcdd0c2..b662a64d84 100644 --- a/panel/package.json +++ b/panel/package.json @@ -1,6 +1,6 @@ { "name": "@holoviz/panel", - "version": "1.5.2", + "version": "1.5.3-a.1", "description": "The powerful data exploration & web app framework for Python.", "license": "BSD-3-Clause", "repository": { From 7ee8cd67d7408ed76722eb04c85eae90219c4163 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 18 Oct 2024 08:58:20 -0400 Subject: [PATCH 26/33] Add test (#7418) --- panel/reactive.py | 7 ++++--- panel/tests/widgets/test_tables.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/panel/reactive.py b/panel/reactive.py index 7f5569365f..81b0a076ba 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -1405,9 +1405,10 @@ def _process_data(self, data: Mapping[str, list | dict[int, Any] | np.ndarray]) old_data = getattr(self, self._data_params[0]) try: if old_data is self.value: # type: ignore - with param.discard_events(self): - self.value = old_raw - self.value = old_data + with _syncing(self, ['value']): + with param.discard_events(self): + self.value = old_raw + self.value = old_data else: self.param.trigger('value') finally: diff --git a/panel/tests/widgets/test_tables.py b/panel/tests/widgets/test_tables.py index 5d82e41070..5d93c50773 100644 --- a/panel/tests/widgets/test_tables.py +++ b/panel/tests/widgets/test_tables.py @@ -165,6 +165,24 @@ def test_dataframe_process_data_event(dataframe): pd.testing.assert_frame_equal(table.value, df) +@pytest.mark.parametrize('widget', [DataFrame, Tabulator]) +def test_dataframe_process_data_no_unsync(dataframe, widget): + df = dataframe.copy() + + table1 = widget(dataframe.copy()) + table2 = widget(table1.param.value.rx().copy()) + table1._process_events({'data': {'int': [5, 7, 9]}}) + df['int'] = [5, 7, 9] + pd.testing.assert_frame_equal(table1.value, df) + pd.testing.assert_frame_equal(table2.value, df) + + # Simulate edit to unsync + table2._process_events({'data': {'int': [3, 2, 4]}}) + + table1.value = dataframe.copy() + pd.testing.assert_frame_equal(table2.value, dataframe) + + def test_dataframe_duplicate_column_name(document, comm): df = pd.DataFrame([[1, 1], [2, 2]], columns=['col', 'col']) with pytest.raises(ValueError): From c931756e989f220dd52cbacbc4480e4694eb39df Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 18 Oct 2024 11:02:26 -0400 Subject: [PATCH 27/33] Bump Tabulator version to 6.3 (#7419) --- panel/compiler.py | 6 +++++- .../dist/css/tabulator_fast.min.css | 0 panel/models/tabulator.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) rename panel/dist/bundled/datatabulator/{tabulator-tables@6.2.1 => tabulator-tables@6.3.0}/dist/css/tabulator_fast.min.css (100%) diff --git a/panel/compiler.py b/panel/compiler.py index 159d33f4cd..108b1b7095 100644 --- a/panel/compiler.py +++ b/panel/compiler.py @@ -350,12 +350,16 @@ def bundle_icons(verbose=False, external=True, download_list=None): def patch_tabulator(): # https://github.com/olifolkerd/tabulator/issues/4421 - path = BUNDLE_DIR / 'datatabulator' / 'tabulator-tables@6.2.1' / 'dist' / 'js' / 'tabulator.min.js' + path = BUNDLE_DIR / 'datatabulator' / 'tabulator-tables@6.3.0' / 'dist' / 'js' / 'tabulator.min.js' text = path.read_text() old = '"focus"!==this.options("editTriggerEvent")&&"click"!==this.options("editTriggerEvent")' new = '"click"!==this.options("editTriggerEvent")' assert text.count(old) == 1 text = text.replace(old, new) + old = '(i=!0,this.subscribed("table-resize")?this.dispatch("table-resize"):this.redraw())' + new = '(i=!0,this.redrawing||(this.redrawing=!0,this.subscribed("table-resize")?this.dispatch("table-resize"):this.redraw(),this.redrawing=!1))' + assert text.count(old) == 1 + text = text.replace(old, new) path.write_text(text) def bundle_resources(verbose=False, external=True): diff --git a/panel/dist/bundled/datatabulator/tabulator-tables@6.2.1/dist/css/tabulator_fast.min.css b/panel/dist/bundled/datatabulator/tabulator-tables@6.3.0/dist/css/tabulator_fast.min.css similarity index 100% rename from panel/dist/bundled/datatabulator/tabulator-tables@6.2.1/dist/css/tabulator_fast.min.css rename to panel/dist/bundled/datatabulator/tabulator-tables@6.3.0/dist/css/tabulator_fast.min.css diff --git a/panel/models/tabulator.py b/panel/models/tabulator.py index 1d37c57ab7..a98b5b0f84 100644 --- a/panel/models/tabulator.py +++ b/panel/models/tabulator.py @@ -16,7 +16,7 @@ from ..util import classproperty from .layout import HTMLBox -TABULATOR_VERSION = "6.2.1" +TABULATOR_VERSION = "6.3.0" JS_SRC = f"{config.npm_cdn}/tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js" MOMENT_SRC = f"{config.npm_cdn}/luxon/build/global/luxon.min.js" From cc3ecc1e7d7ccbc22f92e4bdef0923a2a240cfc5 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 18 Oct 2024 11:12:21 -0400 Subject: [PATCH 28/33] Set table null formatter to empty string (#7421) --- panel/widgets/tables.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index 2ac704603b..6feb6d61e5 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -28,7 +28,7 @@ from ..io.state import state from ..reactive import Reactive, ReactiveData from ..util import ( - clone_model, datetime_as_utctimestamp, isdatetime, lazy_load, + BOKEH_GE_3_6, clone_model, datetime_as_utctimestamp, isdatetime, lazy_load, styler_update, updating, ) from ..util.warnings import warn @@ -241,7 +241,10 @@ def _get_column_definitions(self, col_names: list[str], df: pd.DataFrame) -> lis date_format = '%Y-%m-%d %H:%M:%S' formatter = DateFormatter(format=date_format, text_align='right') else: - formatter = StringFormatter() + params = {} + if BOKEH_GE_3_6: + params['null_format'] = '' + formatter = StringFormatter(**params) default_text_align = True else: From 4c56f74d21104b3d774f5b3cb509f6c43871d8bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Oct 2024 17:12:46 +0200 Subject: [PATCH 29/33] Bump django from 3.2.25 to 4.2.16 in /examples/apps/django (#7413) Bumps [django](https://github.com/django/django) from 3.2.25 to 4.2.16. - [Commits](https://github.com/django/django/compare/3.2.25...4.2.16) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/apps/django/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/apps/django/requirements.txt b/examples/apps/django/requirements.txt index 5784247ed4..fe8429e5cd 100644 --- a/examples/apps/django/requirements.txt +++ b/examples/apps/django/requirements.txt @@ -1,4 +1,4 @@ -django==3.2.25 +django==4.2.16 channels==2.2.0 panel==0.9.3 bokeh==2.0.2 From 5efff822c7198b206c09fecebeaffdb57e6ca272 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 18 Oct 2024 11:12:58 -0400 Subject: [PATCH 30/33] Do not mutate Children inplace (#7417) --- panel/tests/layout/test_base.py | 28 ++++++++++++++++++++++++++++ panel/viewable.py | 24 ++++++++++++++++-------- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/panel/tests/layout/test_base.py b/panel/tests/layout/test_base.py index 2b180e9a0d..422cbce9fc 100644 --- a/panel/tests/layout/test_base.py +++ b/panel/tests/layout/test_base.py @@ -441,6 +441,34 @@ def test_layout_clone_objects_in_kwargs(panel): assert clone.sizing_mode == 'stretch_height' +@pytest.mark.parametrize('panel', [Column, Row]) +def test_layout_children_not_copied(panel): + layout = panel() + + objects = [Markdown(), Markdown()] + layout.objects = objects + + assert layout.objects is objects + + +@pytest.mark.parametrize('panel', [Column, Row]) +def test_layout_children_not_mutated_inplace(panel): + layout = panel() + + objects = [Div(), Div()] + layout.objects = objects + + assert layout.objects is not objects + assert layout.objects[0].object is objects[0] + assert layout.objects[1].object is objects[1] + + layout[:] = objects + + assert layout.objects is not objects + assert layout.objects[0].object is objects[0] + assert layout.objects[1].object is objects[1] + + @pytest.mark.parametrize('panel', [Column, Row]) def test_layout_clone_objects_in_args_and_kwargs(panel): div1 = Div() diff --git a/panel/viewable.py b/panel/viewable.py index 92e5ea4670..f346df18e6 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -1149,10 +1149,14 @@ def __init__( def _transform_value(self, val): if isinstance(val, list) and val: from .pane import panel - val[:] = [ - v if isinstance(v, Viewable) else panel(v) - for v in val - ] + new = [] + mutated = False + for v in val: + n = panel(v) + mutated |= v is not n + new.append(n) + if mutated: + val = new return val @instance_descriptor @@ -1173,10 +1177,14 @@ def __init__( def _transform_value(self, val): if isinstance(val, dict) and val: from .pane import panel - val.update({ - k: v if isinstance(v, Viewable) else panel(v) - for k, v in val.items() - }) + new = {} + mutated = False + for k, v in val.items(): + n = panel(v) + mutated |= v is not n + new[k] = n + if mutated: + val = new return val @instance_descriptor From 516b9e08f116748f8c35844b1f82e8cb36c3b494 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 18 Oct 2024 17:17:50 +0200 Subject: [PATCH 31/33] Update CHANGELOG for 1.5.3b1 --- CHANGELOG.md | 8 +++++++- doc/about/releases.md | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8374fd560c..2b25aac1b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ This release fixes a number of smaller regressions related to `Tabulator` `row_c ### Enhancements - Sync dark mode on `browser_info` object ([#7382](https://github.com/holoviz/panel/pull/7382)) -- Allow loading custom component ESM bundles from URL ([#7410](https://github.com/holoviz/panel/pull/7410)) +- Allow loading custom component ESM bundles from URL ([#7410](https://github.com/holoviz/panel/pull/7410), [#7412](https://github.com/holoviz/panel/pull/7412)) ### Bug fixes @@ -18,6 +18,12 @@ This release fixes a number of smaller regressions related to `Tabulator` `row_c - Fix `ReactiveESM._importmap` typing ([#7392](https://github.com/holoviz/panel/pull/7392)) - Ensure that autoreload records modules to watch before startup ([#7399](https://github.com/holoviz/panel/pull/7399)) - Prevent `Tabulator` from overlapping when `max_height` is set ([#7403](https://github.com/holoviz/panel/pull/7403)) +- Do not mutate layout `Children` inplace ([#7417](https://github.com/holoviz/panel/pull/7403)) +- Set `Tabulator` null formatter to empty string ([#7421](https://github.com/holoviz/panel/pull/7421)) + +### Compatibility + +- Upgraded `Tabulator` version to 6.3 ([#7419](https://github.com/holoviz/panel/pull/7419)) ### Documentation diff --git a/doc/about/releases.md b/doc/about/releases.md index b6050cec82..1972d30591 100644 --- a/doc/about/releases.md +++ b/doc/about/releases.md @@ -9,7 +9,7 @@ This release fixes a number of smaller regressions related to `Tabulator` `row_c ### Enhancements - Sync dark mode on `browser_info` object ([#7382](https://github.com/holoviz/panel/pull/7382)) -- Allow loading custom component ESM bundles from URL ([#7410](https://github.com/holoviz/panel/pull/7410)) +- Allow loading custom component ESM bundles from URL ([#7410](https://github.com/holoviz/panel/pull/7410), [#7412](https://github.com/holoviz/panel/pull/7412)) ### Bug fixes @@ -20,6 +20,12 @@ This release fixes a number of smaller regressions related to `Tabulator` `row_c - Fix `ReactiveESM._importmap` typing ([#7392](https://github.com/holoviz/panel/pull/7392)) - Ensure that autoreload records modules to watch before startup ([#7399](https://github.com/holoviz/panel/pull/7399)) - Prevent `Tabulator` from overlapping when `max_height` is set ([#7403](https://github.com/holoviz/panel/pull/7403)) +- Do not mutate layout `Children` inplace ([#7417](https://github.com/holoviz/panel/pull/7403)) +- Set `Tabulator` null formatter to empty string ([#7421](https://github.com/holoviz/panel/pull/7421)) + +### Compatibility + +- Upgraded `Tabulator` version to 6.3 ([#7419](https://github.com/holoviz/panel/pull/7419)) ### Documentation From 9237b13ecf535cf34adb9b582abf097e5e2b65d8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 18 Oct 2024 17:20:14 +0200 Subject: [PATCH 32/33] Bump panel.js version to 1.5.3-b.1 --- panel/package-lock.json | 4 ++-- panel/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/package-lock.json b/panel/package-lock.json index e0c750f4cf..bfbae0483d 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -1,12 +1,12 @@ { "name": "@holoviz/panel", - "version": "1.5.3-a.1", + "version": "1.5.3-b.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@holoviz/panel", - "version": "1.5.3-a.1", + "version": "1.5.3-b.1", "license": "BSD-3-Clause", "dependencies": { "@bokeh/bokehjs": "3.6.0", diff --git a/panel/package.json b/panel/package.json index b662a64d84..248b178691 100644 --- a/panel/package.json +++ b/panel/package.json @@ -1,6 +1,6 @@ { "name": "@holoviz/panel", - "version": "1.5.3-a.1", + "version": "1.5.3-b.1", "description": "The powerful data exploration & web app framework for Python.", "license": "BSD-3-Clause", "repository": { From c509a44543860a6e08849805cad23675837cd6e7 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 18 Oct 2024 16:17:37 -0700 Subject: [PATCH 33/33] add docstrings, allow triggering views, bugfixes --- panel/models/fullcalendar.js | 34 ++++-- panel/widgets/calendar.py | 207 ++++++++++++++++++++++++++--------- 2 files changed, 182 insertions(+), 59 deletions(-) diff --git a/panel/models/fullcalendar.js b/panel/models/fullcalendar.js index 532d8dae93..04e8ac1813 100644 --- a/panel/models/fullcalendar.js +++ b/panel/models/fullcalendar.js @@ -7,16 +7,14 @@ export function render({model, el}) { buttonIcons: model.button_icons, buttonText: model.button_text, contentHeight: model.content_height, - dateAlignment: model.date_alignment, dateIncrement: model.date_increment, events: model.value, expandRows: model.expand_rows, footerToolbar: model.footer_toolbar, handleWindowResize: model.handle_window_resize, headerToolbar: model.header_toolbar, - initialDate: model.initial_date, - initialView: model.initial_view, - multiMonthMaxColumns: model.multi_month_max_columns, + initialView: model.current_view, + navLinks: model.nav_links, nowIndicator: model.now_indicator, plugins, showNonCurrentDates: model.show_non_current_dates, @@ -29,8 +27,30 @@ export function render({model, el}) { datesSet(info) { model.send_msg({current_date: calendar.getDate().toISOString()}) }, + viewClassNames(info) { + model.send_msg({current_view: info.view.type}) + }, + navLinkDayClick(date, jsEvent) { + calendar.changeView("timeGridDay", date) + }, + navLinkWeekClick(weekStart, jsEvent) { + }, }) + // there's initialDate, but if it's set, there's buggy behavior on re-renders + if (model.current_date) { + calendar.gotoDate(model.current_date) + } + + // these cannot be set in the constructor if null + if (model.dateAlignment) { + calendar.setOption("dateAlignment", model.dateAlignment) + } + + if (model.current_view == "multiMonth") { + calendar.setOption("multiMonthMaxColumns", model.multi_month_max_columns) + } + if (model.aspect_ratio) { calendar.setOption("aspectRatio", model.aspect_ratio) } @@ -50,10 +70,10 @@ export function render({model, el}) { calendar.gotoDate(event.date) } else if (event.type === "incrementDate") { calendar.incrementDate(event.increment) - } else if (event.type === "updateSize") { - calendar.updateSize() } else if (event.type === "updateOption") { calendar.setOption(event.key, event.value) + } else if (event.type === "changeView") { + calendar.changeView(event.view, event.date) } }) calendar.render() @@ -61,7 +81,7 @@ export function render({model, el}) { const plugins = [] function loadPluginIfNeeded(viewName, pluginName) { - if (model.initial_view.startsWith(viewName) || + if (model.current_view.startsWith(viewName) || (model.header_toolbar && Object.values(model.header_toolbar).some(v => v.includes(viewName))) || (model.footer_toolbar && Object.values(model.footer_toolbar).some(v => v.includes(viewName)))) { return import(`@fullcalendar/${pluginName}`).then(plugin => { diff --git a/panel/widgets/calendar.py b/panel/widgets/calendar.py index 9b93bc8203..a2b568c87d 100644 --- a/panel/widgets/calendar.py +++ b/panel/widgets/calendar.py @@ -8,9 +8,30 @@ THIS_DIR = Path(__file__).parent MODELS_DIR = THIS_DIR.parent / "models" +VIEW_DEFAULT_INCREMENTS = { + "dayGridMonth": {"days": 1}, + "dayGridWeek": {"weeks": 1}, + "dayGridDay": {"days": 1}, + "timeGridWeek": {"weeks": 1}, + "timeGridDay": {"days": 1}, + "listWeek": {"weeks": 1}, + "listMonth": {"months": 1}, + "listYear": {"years": 1}, + "multiMonthYear": {"years": 1}, +} class Calendar(JSComponent): + """ + The Calendar widget is a wrapper around the FullCalendar library. + See https://fullcalendar.io/docs for more information on the parameters. + + Reference: https://panel.holoviz.org/reference/widgets/Calendar.html + + :Example: + + >>> pn.widgets.Calendar(value=[{"date": "2024-10-01", "type": "event"}]) + """ aspect_ratio = param.Number( default=None, doc="Sets the width-to-height aspect ratio of the calendar." @@ -34,12 +55,32 @@ class Calendar(JSComponent): default=None, doc="Sets the height of the view area of the calendar." ) - current_date = param.Date( - default=None, doc="The current date of the calendar view." + # using String instead of Date because fullcalendar supports fuzzy dates + current_date = param.String( + default=None, + constant=True, + doc="The onload or current date of the calendar view. Use go_to_date() to change the date.", + ) + + current_view = param.Selector( + default="dayGridMonth", + objects=[ + "dayGridMonth", + "dayGridWeek", + "dayGridDay", + "timeGridWeek", + "timeGridDay", + "listWeek", + "listMonth", + "listYear", + "multiMonthYear", + ], + constant=True, + doc="The onload or current view of the calendar. Use change_view() to change the view.", ) date_alignment = param.String( - default="month", doc="Determines how certain views should be initially aligned." + default=None, doc="Determines how certain views should be initially aligned." ) date_increment = param.String( @@ -67,28 +108,7 @@ class Calendar(JSComponent): "center": "title", "right": "dayGridMonth,timeGridWeek,timeGridDay,listWeek", }, - doc="Defines the buttons and title at the top of the calendar.", - ) - - initial_date = param.Date( - default=None, - doc="The initial date the calendar should display when first loaded.", - ) - - initial_view = param.Selector( - default="dayGridMonth", - objects=[ - "dayGridMonth", - "dayGridWeek", - "dayGridDay", - "timeGridWeek", - "timeGridDay", - "listWeek", - "listMonth", - "listYear", - "multiMonthYear", - ], - doc="The initial view when the calendar loads.", + doc="Defines the buttons and title at the top of the calendar." ) multi_month_max_columns = param.Integer( @@ -96,8 +116,13 @@ class Calendar(JSComponent): doc="Determines the maximum number of columns in the multi-month view.", ) + nav_links = param.Boolean( + default=True, + doc="Turns various datetime text into clickable links that the user can use for navigation.", + ) + now_indicator = param.Boolean( - default=False, doc="Whether to display an indicator for the current time." + default=True, doc="Whether to display an indicator for the current time." ) show_non_current_dates = param.Boolean( @@ -171,6 +196,7 @@ def __init__(self, **params): "handle_window_resize", "header_toolbar", "multi_month_max_columns", + "nav_links", "now_indicator", "show_non_current_dates", "sticky_footer_scrollbar", @@ -183,46 +209,114 @@ def __init__(self, **params): ], ) - def next(self): + def click_next(self) -> None: + """ + Click the next button through the calendar's UI. + """ self._send_msg({"type": "next"}) - def prev(self): + def click_prev(self) -> None: + """ + Click the previous button through the calendar's UI. + """ self._send_msg({"type": "prev"}) - def prev_year(self): + def click_prev_year(self) -> None: + """ + Click the previous year button through the calendar's UI. + """ self._send_msg({"type": "prevYear"}) - def next_year(self): + def click_next_year(self) -> None: + """ + Click the next year button through the calendar's UI. + """ self._send_msg({"type": "nextYear"}) - def today(self): + def click_today(self) -> None: + """ + Click the today button through the calendar's UI. + """ self._send_msg({"type": "today"}) - def go_to_date(self, date): - self._send_msg({"type": "gotoDate", "date": date.isoformat()}) - - def increment_date(self, increment): + def change_view( + self, + view: str, + date: str | datetime.datetime | datetime.date | int | None = None, + ) -> None: + """ + Change the current view of the calendar, and optionally go to a specific date. + + Args: + view: The view to change to. + Options: "dayGridMonth", "dayGridWeek", "dayGridDay", "timeGridWeek", "timeGridDay", + "listWeek", "listMonth", "listYear", "multiMonthYear". + date: The date to go to after changing the view; if None, the current date will be used. + Supports ISO 8601 date strings, datetime/date objects, and int in milliseconds. + """ + self._send_msg({"type": "changeView", "view": view, "date": date}) + + def go_to_date(self, date: str | datetime.datetime | datetime.date | int) -> None: + """ + Go to a specific date on the calendar. + + Args: + date: The date to go to. + Supports ISO 8601 date strings, datetime/date objects, and int in milliseconds. + """ + self._send_msg({"type": "gotoDate", "date": date}) + + def increment_date( + self, increment: str | datetime.timedelta | int | dict | None = None + ) -> None: + """ + Increment the current date by a specific amount. + + Args: + increment: The amount to increment the current date by. + Supports a string in the format hh:mm:ss.sss, hh:mm:sss or hh:mm, an int in milliseconds, + datetime.timedelta objects, or a dict with any of the following keys: + year, years, month, months, day, days, minute, minutes, second, + seconds, millisecond, milliseconds, ms. + If not provided, the date_increment parameter will be used. + If date_increment is not set, the default increment for the current view will be used: + dayGridMonth: {"days": 1} + dayGridWeek: {"weeks": 1} + dayGridDay: {"days": 1} + timeGridWeek: {"weeks": 1} + timeGridDay: {"days": 1} + listWeek: {"weeks": 1} + listMonth: {"months": 1} + listYear: {"years": 1} + multiMonthYear: {"years": 1} + """ + + if increment is None and self.date_increment is None: + increment = VIEW_DEFAULT_INCREMENTS[self.current_view] self._send_msg({"type": "incrementDate", "increment": increment}) - def update_size(self): - self._send_msg({"type": "updateSize"}) - - def _handle_msg(self, msg): - if "current_date" in msg: - self.current_date = datetime.datetime.strptime( - msg["current_date"], "%Y-%m-%dT%H:%M:%S.%fZ" - ) - else: - raise NotImplementedError(f"Unhandled message: {msg}") - def add_event( self, - start: str, - end: str | None = None, + start: str | datetime.datetime | datetime.date | int, + end: str | datetime.datetime | datetime.date | int | None = None, title: str = "(no title)", all_day: bool = False, **kwargs, - ): + ) -> None: + """ + Add an event to the calendar. + + Args: + start: The start date of the event. + Supports ISO 8601 date strings, datetime/date objects, and int in milliseconds. + end: The end date of the event. + Supports ISO 8601 date strings, datetime/date objects, and int in milliseconds. + If None, the event will be all-day. + title: The title of the event. + all_day: Whether the event is an all-day event. + **kwargs: Additional properties to set on the event + """ + event = { "start": start, "end": end, @@ -232,13 +326,22 @@ def add_event( } self.value.append(event) + def _handle_msg(self, msg): + if "current_date" in msg: + with param.edit_constant(self): + self.current_date = msg["current_date"] + elif "current_view" in msg: + with param.edit_constant(self): + self.current_view = msg["current_view"] + else: + raise NotImplementedError(f"Unhandled message: {msg}") + def _update_option(self, event): def to_camel_case(string): return "".join( word.capitalize() if i else word for i, word in enumerate(string.split("_")) ) + key = to_camel_case(event.name) - self._send_msg( - {"type": "updateOption", "key": key, "value": event.new} - ) + self._send_msg({"type": "updateOption", "key": key, "value": event.new})