From 019f01528de3c60a6f8f58903343616e689a7e00 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Thu, 3 Oct 2024 16:34:12 -0700 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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