openai/chatkit-python

Public

mirrored from https://github.com/openai/chatkit-pythonAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
ff2d800fad51513ffa69bf546b64ccdc2fbad151

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

tests/helpers/mock_widget.py

1909lines · modecode

1import os
2import re
3import uuid
4from datetime import datetime, timedelta
5from typing import Annotated, Any, AsyncIterator, Callable, Literal, assert_never
6
7from agents import Agent, Runner
8from anyio import sleep
9from pydantic import BaseModel, Field, TypeAdapter
10
11from chatkit.actions import Action, ActionConfig
12from chatkit.types import ThreadStreamEvent
13from chatkit.widgets import (
14 Box,
15 Button,
16 Card,
17 Col,
18 DatePicker,
19 Divider,
20 Icon,
21 Image,
22 ListView,
23 ListViewItem,
24 Markdown,
25 Row,
26 Select,
27 Spacer,
28 Text,
29 Title,
30 Transition,
31 WidgetComponent,
32 WidgetRoot,
33 WidgetStatus,
34)
35
36from .email_data import (
37 BRAINSTORM_SESSION,
38 CHATKIT_ROADMAP,
39 FLIGHT_REMINDER,
40)
41
42# HELPERS
43
44
45def _gen_id(prefix: str) -> str:
46 return f"{prefix}_{uuid.uuid4().hex}"
47
48
49chatkit_address = os.getenv("CHATKIT_ADDRESS") or "http://localhost:3000"
50
51
52def asset(path: str) -> str:
53 """
54 Helper function to create an asset URL.
55 """
56 return f"{chatkit_address}/{path}"
57
58
59## STATE MODELS
60
61
62class DraftTask(BaseModel):
63 title: str
64 description: str
65 priority: Literal["low", "high"] = "low"
66 timeframe: Literal["today", "tomorrow", "week", "month", "custom"] = "tomorrow"
67 due_date: datetime | None = None
68
69 def model_post_init(self, __context) -> None:
70 if self.due_date is None:
71 self.due_date = self._calculate_due_date()
72
73 def _calculate_due_date(self) -> datetime:
74 now = datetime.now()
75 if self.timeframe == "today":
76 return now.replace(hour=23, minute=59, second=59, microsecond=0)
77 elif self.timeframe == "tomorrow":
78 return (now + timedelta(days=1)).replace(
79 hour=23, minute=59, second=59, microsecond=0
80 )
81 elif self.timeframe == "week":
82 # End of this week (Sunday)
83 days_until_sunday = 6 - now.weekday()
84 return (now + timedelta(days=days_until_sunday)).replace(
85 hour=23, minute=59, second=59, microsecond=0
86 )
87 elif self.timeframe == "month":
88 # End of this month
89 if now.month == 12:
90 next_month = now.replace(year=now.year + 1, month=1, day=1)
91 else:
92 next_month = now.replace(month=now.month + 1, day=1)
93 return (next_month - timedelta(days=1)).replace(
94 hour=23, minute=59, second=59, microsecond=0
95 )
96 else: # custom - treat as today for now
97 return now.replace(hour=23, minute=59, second=59, microsecond=0)
98
99 def priority_color(self) -> str:
100 if self.priority == "high":
101 return "red-400"
102 return "secondary"
103
104 def urgency_color(self) -> str:
105 if self.due_date is None:
106 self.due_date = self._calculate_due_date()
107
108 now = datetime.now(self.due_date.tzinfo)
109 due = self.due_date
110 days_until_due = (due - now).days
111
112 if days_until_due < 1:
113 return "red-400"
114
115 if days_until_due < 3:
116 return "yellow-600"
117
118 return "secondary"
119
120 def humanized_due_date(self) -> str:
121 if self.due_date is None:
122 self.due_date = self._calculate_due_date()
123
124 now = datetime.now(self.due_date.tzinfo)
125 due = self.due_date
126
127 # Check if it's overdue
128 if due < now:
129 days_overdue = (now - due).days
130 if days_overdue == 0:
131 return "Due today"
132 elif days_overdue == 1:
133 return "Due yesterday"
134 else:
135 return f"Due {days_overdue} days ago"
136
137 # Check future dates
138 days_until_due = (due - now).days
139
140 if days_until_due == 0:
141 return "Due today"
142 elif days_until_due == 1:
143 return "Due tomorrow"
144 elif days_until_due <= 7:
145 return f"Due in {days_until_due} days"
146 elif days_until_due <= 14:
147 return "Due next week"
148 else:
149 return f"Due on {due.strftime('%b %d')}"
150
151
152class DraftCalendarEvent(BaseModel):
153 title: str
154 description: str
155 date: str
156 start_time: str
157 end_time: str
158 calendar: Literal["Work", "Personal"]
159
160 def calendar_color(self) -> str:
161 return "blue-400" if self.calendar == "Work" else "red-400"
162
163 def time(self) -> str:
164 return f"{self.start_time} - {self.end_time}"
165
166
167class DraftEmail(BaseModel):
168 subject: str
169 body: str
170 to: str
171
172
173class Email(DraftEmail):
174 id: str = Field(default_factory=lambda: _gen_id("email"))
175 sender_image: str
176 sender: str
177 sender_type: Literal["org", "person"]
178 sent_at: str
179
180
181class Task(DraftTask):
182 id: str = Field(default_factory=lambda: _gen_id("task"))
183 completed: bool
184
185
186class CalendarEvent(DraftCalendarEvent):
187 id: str = Field(default_factory=lambda: _gen_id("event"))
188
189
190class BaseWidgetView(BaseModel):
191 status_text: str | None = None
192 status_icon: str = "favicon.svg"
193 collapsed: bool = False
194
195 def status(self) -> WidgetStatus | None:
196 if not self.status_text:
197 return None
198 return {"text": self.status_text, "favicon": asset(self.status_icon)}
199
200
201class EmailDraft(BaseWidgetView):
202 type: Literal["email_draft"] = Field(default="email_draft", frozen=True)
203 email: DraftEmail
204 streaming: bool
205 status_icon: str = "gmail-send-status-icon.png"
206
207
208class EmailView(BaseWidgetView):
209 type: Literal["email_view"] = Field(default="email_view", frozen=True)
210 email: Email
211 show_back_button: bool
212 status_icon: str = "gmail-status-icon.png"
213
214
215class EmailsList(BaseWidgetView):
216 type: Literal["emails_list"] = Field(default="emails_list", frozen=True)
217 emails: list[Email]
218 status_icon: str = "gmail-status-icon.png"
219
220
221class TaskDraft(BaseWidgetView):
222 type: Literal["task_draft"] = Field(default="task_draft", frozen=True)
223 todo: DraftTask
224 status_icon: str = "linear-status-icon.png"
225
226
227class TaskView(BaseWidgetView):
228 type: Literal["task_view"] = Field(default="task_view", frozen=True)
229 task: Task
230 show_back_button: bool
231 status_icon: str = "linear-status-icon.png"
232
233
234class TasksList(BaseWidgetView):
235 type: Literal["tasks_list"] = Field(default="tasks_list", frozen=True)
236 tasks: list[Task]
237 status_icon: str = "linear-status-icon.png"
238
239
240class CalendarEventDraft(BaseWidgetView):
241 type: Literal["calendar_event_draft"] = Field(
242 default="calendar_event_draft", frozen=True
243 )
244 event: DraftCalendarEvent
245 status_icon: str = "calendar-status-icon.png"
246
247
248class CalendarEventView(BaseWidgetView):
249 type: Literal["calendar_event_view"] = Field(
250 default="calendar_event_view", frozen=True
251 )
252 event: CalendarEvent
253 show_back_button: bool
254 status_icon: str = "calendar-status-icon.png"
255
256
257class CalendarEventsList(BaseWidgetView):
258 type: Literal["calendar_events_list"] = Field(
259 default="calendar_events_list", frozen=True
260 )
261 events: list[CalendarEvent]
262 status_icon: str = "calendar-status-icon.png"
263
264
265class Index(BaseWidgetView):
266 type: Literal["index"] = Field(default="index", frozen=True)
267 selected: Literal["email", "calendar", "tasks", "index"]
268
269 def get_section_icon(self, section: str) -> str:
270 """Get the appropriate icon for each section"""
271 icon_map = {
272 "email": "gmail-status-icon.png",
273 "calendar": "calendar-status-icon.png",
274 "tasks": "linear-status-icon.png",
275 "index": "favicon.svg",
276 }
277 return icon_map.get(section, "favicon.svg")
278
279 def __init__(self, **data):
280 super().__init__(**data)
281 # Set status_icon based on current selection
282 if self.selected:
283 self.status_icon = self.get_section_icon(self.selected)
284 else:
285 self.status_icon = "favicon.svg"
286
287
288State = Annotated[
289 EmailDraft
290 | EmailView
291 | EmailsList
292 | TaskDraft
293 | TaskView
294 | TasksList
295 | CalendarEventDraft
296 | CalendarEventView
297 | CalendarEventsList
298 | Index,
299 Field(discriminator="type"),
300]
301
302
303## ACTIONS
304
305
306class BasePayload(BaseModel):
307 widget_id: str
308
309
310class ShowWidgetPayload(BasePayload):
311 widget: Literal["email", "calendar", "tasks", "index"]
312
313
314class SendEmailPayload(BasePayload):
315 email: DraftEmail
316
317
318class ViewEmailPayload(BasePayload):
319 email_id: str
320
321
322class UpdateDraftTaskPayload(BasePayload):
323 todo: DraftTask
324
325
326class CreateTaskPayload(BasePayload):
327 todo: DraftTask
328
329
330class ViewTaskPayload(BasePayload):
331 task_id: str
332
333
334class ToggleTaskCompletePayload(BasePayload):
335 task_id: str
336
337
338class CreateEventPayload(BasePayload):
339 event: DraftCalendarEvent
340
341
342class ViewEventPayload(BasePayload):
343 event_id: str
344
345
346ShowWidgetAction = Action[Literal["sample.show_widget"], ShowWidgetPayload]
347DraftEmailAction = Action[Literal["sample.draft_email"], BasePayload]
348ShowInboxAction = Action[Literal["sample.show_inbox"], BasePayload]
349SendEmailAction = Action[Literal["sample.send_email"], SendEmailPayload]
350DiscardEmailAction = Action[Literal["sample.discard_email"], BasePayload]
351ViewEmailAction = Action[Literal["sample.view_email"], ViewEmailPayload]
352DraftTaskAction = Action[Literal["sample.draft_task"], BasePayload]
353UpdateDraftTaskAction = Action[
354 Literal["sample.update_draft_task"], UpdateDraftTaskPayload
355]
356CreateTaskAction = Action[Literal["sample.create_task"], CreateTaskPayload]
357CancelTaskAction = Action[Literal["sample.cancel_task"], BasePayload]
358ViewTasksAction = Action[Literal["sample.view_tasks"], BasePayload]
359ViewTaskAction = Action[Literal["sample.view_task"], ViewTaskPayload]
360ToggleTaskCompleteAction = Action[
361 Literal["sample.toggle_task_complete"], ToggleTaskCompletePayload
362]
363DraftEventAction = Action[Literal["sample.draft_event"], BasePayload]
364CreateEventAction = Action[Literal["sample.create_event"], CreateEventPayload]
365DiscardEventAction = Action[Literal["sample.discard_event"], BasePayload]
366ViewScheduleAction = Action[Literal["sample.view_schedule"], BasePayload]
367ViewEventAction = Action[Literal["sample.view_event"], ViewEventPayload]
368
369
370SampleWidgetAction = Annotated[
371 ShowWidgetAction
372 | DraftEmailAction
373 | ShowInboxAction
374 | SendEmailAction
375 | DiscardEmailAction
376 | ViewEmailAction
377 | DraftTaskAction
378 | CreateTaskAction
379 | CancelTaskAction
380 | ViewTasksAction
381 | ViewTaskAction
382 | ToggleTaskCompleteAction
383 | UpdateDraftTaskAction
384 | DraftEventAction
385 | CreateEventAction
386 | DiscardEventAction
387 | ViewScheduleAction
388 | ViewEventAction,
389 Field(discriminator="type"),
390]
391
392ActionAdapter: TypeAdapter[SampleWidgetAction] = TypeAdapter(SampleWidgetAction)
393
394
395email_generator = Agent(
396 model="gpt-4.1",
397 name="Description generator",
398 instructions="""Generate an email about a given subject. Do not include any other text than the email. Do not include the subject itself. The email should end with "\n\nThanks,\nZach"
399 """,
400)
401
402
403class ActionOutput(BaseModel):
404 widget: WidgetRoot | None = None
405 event: ThreadStreamEvent | None = None
406
407 @staticmethod
408 def from_event(event: ThreadStreamEvent) -> "ActionOutput":
409 return ActionOutput(widget=None, event=event)
410
411 @staticmethod
412 def from_widget(widget: WidgetRoot) -> "ActionOutput":
413 return ActionOutput(widget=widget, event=None)
414
415
416class SampleWidget(BaseModel):
417 id: str = Field(default_factory=lambda: _gen_id("wig"))
418 emails: list[Email] = []
419 tasks: list[Task] = []
420 events: list[CalendarEvent] = []
421 state: State = Index(selected="index", status_text="Fetched widgets")
422
423 @staticmethod
424 def parse_action(action: Action[str, Any]) -> SampleWidgetAction:
425 return ActionAdapter.validate_python(action.model_dump())
426
427 def load_events(self):
428 self.events = [
429 CalendarEvent(
430 title="Dentist appointment",
431 description="Regular checkup and cleaning",
432 date="Wed, July 16",
433 start_time="2:00",
434 end_time="3:00 PM",
435 calendar="Personal",
436 ),
437 CalendarEvent(
438 title="Team standup",
439 description="Daily team sync meeting",
440 date="Thu, July 17",
441 start_time="3:30",
442 end_time="4:00 PM",
443 calendar="Work",
444 ),
445 CalendarEvent(
446 title="Q3 roadmap review",
447 description="Quarterly planning session",
448 date="Fri, July 18",
449 start_time="9:00",
450 end_time="10:30 AM",
451 calendar="Work",
452 ),
453 CalendarEvent(
454 title="Lunch with Sarah",
455 description="Catch up over lunch",
456 date="Sat, July 19",
457 start_time="12:00",
458 end_time="1:30 PM",
459 calendar="Personal",
460 ),
461 ]
462
463 def load_emails(self):
464 self.emails = [
465 Email(
466 sender="David Weedon",
467 sender_image=asset("david.png"),
468 sender_type="person",
469 subject="ChatKit Roadmap",
470 body=CHATKIT_ROADMAP,
471 to="zach@chatkit.studio",
472 sent_at="8:40 AM",
473 ),
474 Email(
475 sender="United Airlines",
476 sender_image=asset("united.png"),
477 sender_type="org",
478 subject="Quick reminders about your upcoming trip to San Francisco",
479 body=FLIGHT_REMINDER,
480 to="zach@chatkit.studio",
481 sent_at="8:12 AM",
482 ),
483 Email(
484 sender="Tyler Smith",
485 sender_image=asset("tyler.png"),
486 sender_type="person",
487 subject="re: Morning brainstorm",
488 body=BRAINSTORM_SESSION,
489 to="zach@chatkit.studio",
490 sent_at="Yesterday",
491 ),
492 ]
493
494 def load_tasks(self):
495 self.tasks = [
496 Task(
497 title="Add source annotations to responses",
498 description="Implement source annotations feature to add context to assistant responses. This is currently marked as 'In progress' in the roadmap.",
499 priority="high",
500 timeframe="today",
501 completed=False,
502 ),
503 Task(
504 title="Fix mobile web scrolling issues",
505 description="Address the rough mobile web experience, particularly scrolling and input caret tracking issues mentioned in known issues.",
506 priority="high",
507 timeframe="tomorrow",
508 completed=False,
509 ),
510 Task(
511 title="Implement graceful page refresh handling",
512 description="Fix the issue where refreshing the page while waiting for an assistant message causes the message to be dropped entirely.",
513 priority="high",
514 timeframe="week",
515 completed=False,
516 ),
517 Task(
518 title="Design enhanced interactive widgets",
519 description="Develop richer interactive elements for the widget system. This is planned for the upcoming roadmap.",
520 priority="high",
521 timeframe="month",
522 completed=True,
523 ),
524 Task(
525 title="Add robust attachment error handling",
526 description="Implement proper error handling for attachments to prevent items from staying in uploading state indefinitely.",
527 priority="high",
528 timeframe="week",
529 completed=False,
530 ),
531 Task(
532 title="Implement source scope selection",
533 description="Add the ability for users to choose specific data sources when interacting with the assistant.",
534 priority="low",
535 timeframe="month",
536 completed=False,
537 ),
538 Task(
539 title="Add custom fonts support",
540 description="Enable users to use different typefaces in their ChatKit interface. This is planned for customization features.",
541 priority="low",
542 timeframe="month",
543 completed=False,
544 ),
545 Task(
546 title="Implement dark mode theme switching",
547 description="Complete the theme switching functionality between light and dark modes with proper system integration.",
548 priority="low",
549 timeframe="tomorrow",
550 completed=True,
551 ),
552 Task(
553 title="Set up ChatKit Explorer development environment",
554 description="Configure the complete development setup including dependencies, environment variables, and make commands for local development.",
555 priority="low",
556 timeframe="today",
557 completed=True,
558 ),
559 ]
560
561 def render(
562 self,
563 state: State | None = None,
564 ) -> WidgetRoot:
565 if state is not None:
566 self.state = state
567
568 if self.state.type == "index":
569 return render_index(self.id, self.state)
570 if self.state.type == "email_draft":
571 return render_email_draft(self.id, self.state)
572 if self.state.type == "email_view":
573 return render_email_view(self.id, self.state)
574 if self.state.type == "emails_list":
575 return render_emails_list(self.id, self.state)
576 if self.state.type == "task_draft":
577 return render_task_draft(self.id, self.state)
578 if self.state.type == "task_view":
579 return render_task_view(self.id, self.state)
580 if self.state.type == "tasks_list":
581 return render_tasks_list(self.id, self.state)
582 if self.state.type == "calendar_event_draft":
583 return render_calendar_event_draft(self.id, self.state)
584 if self.state.type == "calendar_event_view":
585 return render_calendar_event_view(self.id, self.state)
586 if self.state.type == "calendar_events_list":
587 return render_calendar_events_list(self.id, self.state)
588
589 assert_never(self.state)
590
591 def render_with_status(self, text: str | None) -> WidgetRoot:
592 self.state.status_text = text
593 return self.render()
594
595 async def save_and_generate(
596 self,
597 next_state: State,
598 generate: Callable[[], AsyncIterator[ThreadStreamEvent]],
599 save: Callable[[], AsyncIterator[ThreadStreamEvent]],
600 ):
601 async for event in save():
602 yield ActionOutput.from_event(event)
603
604 first = True
605 async for event in generate():
606 if first:
607 first = False
608 yield ActionOutput.from_widget(self.render(next_state))
609 yield ActionOutput.from_event(event)
610
611 async def handle_action(
612 self,
613 action: SampleWidgetAction,
614 generate: Callable[[], AsyncIterator[ThreadStreamEvent]],
615 save: Callable[[], AsyncIterator[ThreadStreamEvent]],
616 ) -> AsyncIterator[ActionOutput]:
617 if action.type == "sample.show_widget":
618 next_state = Index(
619 selected=action.payload.widget,
620 status_text=f"Fetched {action.payload.widget} widget"
621 if action.payload.widget != "index"
622 else "Fetched widgets",
623 )
624 yield ActionOutput.from_widget(self.render(next_state))
625
626 elif action.type == "sample.draft_email":
627 with open("../README.md", "r") as f:
628 readme = f.read()
629
630 body_text = Runner.run_streamed(
631 email_generator,
632 "Draft an email asking David about the current status of the ChatKit roadmap. Keep it short and high level, ask questions about some of the items in the readme. Current status in the README:\n\n"
633 + readme,
634 )
635
636 next_email_state: EmailDraft = EmailDraft(
637 status_text="Drafting email",
638 email=DraftEmail(
639 subject="ChatKit Roadmap",
640 body="",
641 to="david@chatkit.studio",
642 ),
643 streaming=True,
644 )
645 yield ActionOutput.from_widget(self.render(next_email_state))
646
647 async for event in body_text.stream_events():
648 if event.type == "raw_response_event":
649 if event.data.type == "response.output_text.delta":
650 next_email_state.email.body += event.data.delta
651 next_email_state.streaming = True
652 yield ActionOutput.from_widget(self.render(next_email_state))
653
654 next_email_state.status_text = "Drafted email"
655 next_email_state.streaming = False
656 yield ActionOutput.from_widget(self.render(next_email_state))
657
658 elif action.type == "sample.show_inbox":
659 if not self.emails:
660 yield ActionOutput.from_widget(
661 self.render_with_status("Fetching inbox")
662 )
663 await sleep(2)
664 self.load_emails()
665 next_state = EmailsList(status_text="Fetched inbox", emails=self.emails)
666 yield ActionOutput.from_widget(self.render(next_state))
667
668 elif action.type == "sample.send_email":
669 if self.state.type != "email_draft":
670 raise ValueError("Invalid state for sending email")
671 self.state.email = action.payload.email
672 self.state.status_text = "Sending"
673 yield ActionOutput.from_widget(self.render())
674 email = Email(
675 sender="Zach Johnston",
676 sender_image=asset("zach.png"),
677 sender_type="person",
678 sent_at="Just now",
679 subject=action.payload.email.subject,
680 body=action.payload.email.body,
681 to=action.payload.email.to,
682 )
683 next_state = EmailView(
684 status_text="Sent email",
685 status_icon="gmail-send-status-icon.png",
686 email=email,
687 show_back_button=False,
688 )
689 async for event in self.save_and_generate(next_state, generate, save):
690 yield event
691
692 elif action.type == "sample.discard_email":
693 yield ActionOutput.from_widget(self.render_with_status("Discarding"))
694 self.state.status_text = "Discarded email draft"
695 self.state.collapsed = True
696 async for event in self.save_and_generate(self.state, generate, save):
697 yield event
698
699 elif action.type == "sample.view_email":
700 for email in self.emails:
701 if email.id == action.payload.email_id:
702 next_state = EmailView(
703 status_text=self.state.status_text,
704 email=email,
705 show_back_button=True,
706 )
707 yield ActionOutput.from_widget(self.render(next_state))
708 break
709 else:
710 raise ValueError(f"Email with id {action.payload.email_id} not found")
711
712 elif action.type == "sample.draft_task":
713 next_state = TaskDraft(
714 status_text="Drafted task",
715 todo=DraftTask(
716 title="Design resizable popup mode",
717 description="Create a design proposal for how ChatKit's popup mode can support dynamic height and user resizing.",
718 priority="low",
719 timeframe="tomorrow",
720 ),
721 )
722 yield ActionOutput.from_widget(self.render(next_state))
723
724 elif action.type == "sample.create_task":
725 if self.state.type != "task_draft":
726 raise ValueError("Invalid state for creating task")
727 self.state.todo = action.payload.todo
728 self.state.status_text = "Creating task"
729 yield ActionOutput.from_widget(self.render())
730 task = Task(
731 title=action.payload.todo.title,
732 description=action.payload.todo.description,
733 priority=action.payload.todo.priority,
734 timeframe=action.payload.todo.timeframe,
735 due_date=action.payload.todo.due_date,
736 completed=False,
737 )
738 self.tasks.append(task)
739 next_state = TaskView(
740 status_text="Created task",
741 task=task,
742 show_back_button=False,
743 )
744 async for event in self.save_and_generate(next_state, generate, save):
745 yield event
746
747 elif action.type == "sample.cancel_task":
748 yield ActionOutput.from_widget(self.render_with_status("Discarding"))
749 self.state.status_text = "Discarded task draft"
750 self.state.collapsed = True
751 async for event in self.save_and_generate(self.state, generate, save):
752 yield event
753
754 elif action.type == "sample.view_tasks":
755 if not self.tasks:
756 yield ActionOutput.from_widget(
757 self.render_with_status("Fetching tasks")
758 )
759 await sleep(2)
760 self.load_tasks()
761 next_state = TasksList(status_text="Fetched tasks", tasks=self.tasks)
762 yield ActionOutput.from_widget(self.render(next_state))
763
764 elif action.type == "sample.view_task":
765 for task in self.tasks:
766 if task.id == action.payload.task_id:
767 next_state = TaskView(
768 status_text=self.state.status_text,
769 task=task,
770 show_back_button=True,
771 )
772 yield ActionOutput.from_widget(self.render(next_state))
773 break
774 else:
775 raise ValueError(f"Task with id {action.payload.task_id} not found")
776
777 elif action.type == "sample.toggle_task_complete":
778 for task in self.tasks:
779 if task.id == action.payload.task_id:
780 task.completed = not task.completed
781 break
782 else:
783 raise ValueError(f"Task with id {action.payload.task_id} not found")
784
785 if self.state.type == "tasks_list":
786 self.state.tasks = self.tasks
787 elif self.state.type == "task_view":
788 self.state.task = task
789
790 yield ActionOutput.from_widget(self.render())
791
792 elif action.type == "sample.update_draft_task":
793 if self.state.type != "task_draft":
794 raise ValueError("Invalid draft task update")
795 self.state.todo = action.payload.todo
796 yield ActionOutput.from_widget(self.render())
797
798 elif action.type == "sample.draft_event":
799 next_state = CalendarEventDraft(
800 status_text="Drafted calendar event",
801 event=DraftCalendarEvent(
802 title="Q3 roadmap review",
803 description="Quarterly planning session to review and align on the Q3 roadmap.",
804 date="Wed 16",
805 start_time="9:00",
806 end_time="10:30 AM",
807 calendar="Work",
808 ),
809 )
810 yield ActionOutput.from_widget(self.render(next_state))
811
812 elif action.type == "sample.create_event":
813 yield ActionOutput.from_widget(
814 self.render_with_status("Creating calendar event")
815 )
816 event = CalendarEvent(
817 title=action.payload.event.title,
818 date=action.payload.event.date,
819 start_time=action.payload.event.start_time,
820 end_time=action.payload.event.end_time,
821 description=action.payload.event.description,
822 calendar=action.payload.event.calendar,
823 )
824 self.events.append(event)
825 next_state = CalendarEventView(
826 status_text="Added event to calendar",
827 event=event,
828 show_back_button=False,
829 )
830 async for event in self.save_and_generate(next_state, generate, save):
831 yield event
832
833 elif action.type == "sample.discard_event":
834 yield ActionOutput.from_widget(self.render_with_status("Discarding"))
835 self.state.status_text = "Discarded draft event"
836 self.state.collapsed = True
837 async for event in self.save_and_generate(self.state, generate, save):
838 yield event
839
840 elif action.type == "sample.view_schedule":
841 if not self.events:
842 yield ActionOutput.from_widget(
843 self.render_with_status("Fetching schedule")
844 )
845 await sleep(2)
846 self.load_events()
847 next_state = CalendarEventsList(
848 status_text="Fetched schedule", events=self.events
849 )
850 yield ActionOutput.from_widget(self.render(next_state))
851
852 elif action.type == "sample.view_event":
853 for event in self.events:
854 if event.id == action.payload.event_id:
855 next_state = CalendarEventView(
856 status_text=self.state.status_text,
857 event=event,
858 show_back_button=True,
859 )
860 yield ActionOutput.from_widget(self.render(next_state))
861 break
862 else:
863 raise ValueError(f"Event with id {action.payload.event_id} not found")
864
865 else:
866 assert_never(action)
867
868
869# HELPER TEMPLATES
870
871
872def back_button_list_item(action: ActionConfig):
873 action.loadingBehavior = "container"
874 return ListViewItem(
875 onClickAction=action,
876 gap=3,
877 children=[
878 Button(
879 size="3xs",
880 iconStart="chevron-left",
881 color="primary",
882 variant="soft",
883 iconSize="sm",
884 label="",
885 pill=True,
886 uniform=True,
887 onClickAction=action,
888 ),
889 Text(value="Back", color="emphasis"),
890 ],
891 )
892
893
894# INDEX TEMPLATES
895
896
897def render_index(id: str, state: Index):
898 if state.selected == "index":
899 return render_widget_list(id, state)
900 if state.selected == "email":
901 return render_email_widget_list(id, state)
902 if state.selected == "calendar":
903 return render_calendar_widget_list(id, state)
904 if state.selected == "tasks":
905 return render_tasks_widget_list(id, state)
906
907 assert_never(state.selected)
908
909
910def render_widget_list(id: str, state: Index):
911 return ListView(
912 status=state.status(),
913 key="index.pick",
914 children=[
915 ListViewItem(
916 onClickAction=ShowWidgetAction.create(
917 ShowWidgetPayload(widget="email", widget_id=id),
918 loading_behavior="container",
919 ),
920 gap=3,
921 children=[
922 Image(
923 src=asset("gmail-list-icon.png"),
924 size="60px",
925 frame=True,
926 ),
927 Col(
928 children=[
929 Text(
930 value="Email widget",
931 weight="medium",
932 color="emphasis",
933 ),
934 Text(
935 value="Craft and preview an email before sending",
936 color="secondary",
937 ),
938 ]
939 ),
940 ],
941 ),
942 ListViewItem(
943 onClickAction=ShowWidgetAction.create(
944 ShowWidgetPayload(widget="calendar", widget_id=id),
945 loading_behavior="container",
946 ),
947 gap=3,
948 children=[
949 Image(
950 src=asset("calendar-list-icon.png"),
951 size="60px",
952 frame=True,
953 ),
954 Col(
955 children=[
956 Text(
957 value="Calendar widget",
958 weight="medium",
959 color="emphasis",
960 ),
961 Text(
962 value="Add events to your calendar",
963 color="secondary",
964 ),
965 ]
966 ),
967 ],
968 ),
969 ListViewItem(
970 onClickAction=ShowWidgetAction.create(
971 ShowWidgetPayload(widget="tasks", widget_id=id),
972 loading_behavior="container",
973 ),
974 gap=3,
975 children=[
976 Image(
977 src=asset("linear-list-icon.png"),
978 size="60px",
979 frame=True,
980 ),
981 Col(
982 children=[
983 Text(
984 value="Tasks widget",
985 weight="medium",
986 color="emphasis",
987 ),
988 Text(
989 value="Manage your tasks and to-dos",
990 color="secondary",
991 ),
992 ]
993 ),
994 ],
995 ),
996 ],
997 )
998
999
1000def render_email_widget_list(id: str, state: Index):
1001 return ListView(
1002 status=state.status(),
1003 key="index.email",
1004 children=[
1005 back_button_list_item(
1006 ShowWidgetAction.create(ShowWidgetPayload(widget="index", widget_id=id))
1007 ),
1008 ListViewItem(
1009 onClickAction=ShowInboxAction.create(
1010 BasePayload(widget_id=id), loading_behavior="container"
1011 ),
1012 gap=3,
1013 children=[
1014 Image(
1015 src=asset("gmail-inbox-icon.png"),
1016 size="40px",
1017 frame=True,
1018 ),
1019 Text(value="View inbox", color="emphasis"),
1020 ],
1021 ),
1022 ListViewItem(
1023 onClickAction=DraftEmailAction.create(BasePayload(widget_id=id)),
1024 gap=3,
1025 children=[
1026 Image(
1027 src=asset("gmail-send-icon.png"),
1028 size="40px",
1029 frame=True,
1030 ),
1031 Text(value="Send an email", color="emphasis"),
1032 ],
1033 ),
1034 ],
1035 )
1036
1037
1038def render_tasks_widget_list(id: str, state: Index):
1039 return ListView(
1040 status=state.status(),
1041 key="index.tasks",
1042 children=[
1043 back_button_list_item(
1044 action=ShowWidgetAction.create(
1045 ShowWidgetPayload(widget="index", widget_id=id)
1046 )
1047 ),
1048 ListViewItem(
1049 onClickAction=ViewTasksAction.create(
1050 BasePayload(widget_id=id), loading_behavior="container"
1051 ),
1052 gap=3,
1053 children=[
1054 Image(src=asset("linear-view-icon.png"), size=40, frame=True),
1055 Text(value="View tasks", color="emphasis"),
1056 ],
1057 ),
1058 ListViewItem(
1059 onClickAction=DraftTaskAction.create(
1060 BasePayload(widget_id=id), loading_behavior="container"
1061 ),
1062 gap=3,
1063 children=[
1064 Image(src=asset("linear-create-icon.png"), size=40, frame=True),
1065 Text(value="Create a task", color="emphasis"),
1066 ],
1067 ),
1068 ],
1069 )
1070
1071
1072def render_calendar_widget_list(id: str, state: Index):
1073 return ListView(
1074 status=state.status(),
1075 key="index.calendar",
1076 children=[
1077 back_button_list_item(
1078 action=ShowWidgetAction.create(
1079 ShowWidgetPayload(widget="index", widget_id=id)
1080 )
1081 ),
1082 ListViewItem(
1083 onClickAction=ViewScheduleAction.create(
1084 BasePayload(widget_id=id), loading_behavior="container"
1085 ),
1086 gap=3,
1087 children=[
1088 Image(
1089 src=asset("calendar-schedule-icon.png"),
1090 size="40px",
1091 frame=True,
1092 ),
1093 Text(value="View schedule", color="emphasis"),
1094 ],
1095 ),
1096 ListViewItem(
1097 onClickAction=DraftEventAction.create(
1098 BasePayload(widget_id=id), loading_behavior="container"
1099 ),
1100 gap=3,
1101 children=[
1102 Image(
1103 src=asset("calendar-create-icon.png"),
1104 size="40px",
1105 frame=True,
1106 ),
1107 Text(value="Create an event", color="emphasis"),
1108 ],
1109 ),
1110 ],
1111 )
1112
1113
1114# EMAIL TEMPLATES
1115
1116
1117def render_email_draft(id: str, state: EmailDraft):
1118 return Card(
1119 key="email.draft",
1120 size="lg",
1121 status=state.status(),
1122 collapsed=state.collapsed,
1123 asForm=True,
1124 confirm={
1125 "label": "Send email",
1126 "action": SendEmailAction.create(
1127 SendEmailPayload(email=state.email, widget_id=id),
1128 loading_behavior="self",
1129 ),
1130 }
1131 if not state.collapsed
1132 else None,
1133 cancel={
1134 "label": "Discard",
1135 "action": DiscardEmailAction.create(BasePayload(widget_id=id)),
1136 }
1137 if not state.collapsed
1138 else None,
1139 children=[
1140 Col(
1141 gap=3,
1142 children=[
1143 Row(
1144 gap=4,
1145 align="baseline",
1146 children=[
1147 Text(
1148 value="TO",
1149 weight="semibold",
1150 color="tertiary",
1151 size="xs",
1152 width=64,
1153 ),
1154 Text(value=state.email.to),
1155 ],
1156 ),
1157 Divider(),
1158 Row(
1159 gap=4,
1160 align="baseline",
1161 children=[
1162 Text(
1163 value="SUBJECT",
1164 weight="semibold",
1165 color="tertiary",
1166 size="xs",
1167 width=64,
1168 ),
1169 Text(
1170 value=state.email.subject,
1171 editable={"name": "subject", "required": True}
1172 if not state.collapsed
1173 and state.status_text != "Sending"
1174 else False,
1175 ),
1176 ],
1177 ),
1178 Divider(),
1179 Row(
1180 flex="auto",
1181 children=[
1182 Text(
1183 width="100%",
1184 value=state.email.body,
1185 streaming=state.streaming,
1186 id="email_body",
1187 minLines=10,
1188 editable={
1189 "name": "body",
1190 "autoFocus": True,
1191 "required": True,
1192 }
1193 if not state.collapsed
1194 and state.status_text != "Sending"
1195 else False,
1196 ),
1197 ],
1198 ),
1199 ],
1200 )
1201 ],
1202 )
1203
1204
1205def render_email_view(id: str, state: EmailView):
1206 footer: list[WidgetComponent] = []
1207 if state.show_back_button:
1208 footer = [
1209 Divider(flush=True),
1210 Row(
1211 children=[
1212 Button(
1213 onClickAction=ShowInboxAction.create(
1214 BasePayload(widget_id=id), loading_behavior="container"
1215 ),
1216 label="Back",
1217 color="primary",
1218 variant="outline",
1219 pill=True,
1220 iconStart="chevron-left",
1221 )
1222 ]
1223 ),
1224 ]
1225
1226 return Card(
1227 key="email.view",
1228 size="lg",
1229 status=state.status(),
1230 children=[
1231 Row(
1232 gap=3,
1233 children=[
1234 Image(
1235 src=state.email.sender_image,
1236 size=40,
1237 radius="full",
1238 frame=True,
1239 ),
1240 Col(
1241 children=[
1242 Text(
1243 value=state.email.sender,
1244 weight="semibold",
1245 size="md",
1246 color="emphasis",
1247 ),
1248 Text(
1249 value=f"To: {state.email.to}",
1250 size="sm",
1251 color="secondary",
1252 ),
1253 ]
1254 ),
1255 Spacer(),
1256 Text(
1257 value=state.email.sent_at or "",
1258 size="sm",
1259 color="secondary",
1260 ),
1261 ],
1262 ),
1263 Divider(flush=True),
1264 Col(
1265 gap=6,
1266 children=[
1267 Text(
1268 value=state.email.subject,
1269 weight="semibold",
1270 size="xl",
1271 color="emphasis",
1272 ),
1273 Markdown(value=state.email.body),
1274 ],
1275 ),
1276 ]
1277 + footer,
1278 )
1279
1280
1281def render_emails_list(id: str, state: EmailsList):
1282 return ListView(
1283 key="email.inbox",
1284 status=state.status(),
1285 children=[
1286 back_button_list_item(
1287 ShowWidgetAction.create(
1288 ShowWidgetPayload(widget="email", widget_id=id),
1289 loading_behavior="container",
1290 )
1291 )
1292 ]
1293 + [
1294 ListViewItem(
1295 onClickAction=ViewEmailAction.create(
1296 ViewEmailPayload(email_id=email.id, widget_id=id)
1297 ),
1298 gap=3,
1299 align="start",
1300 key=email.id,
1301 children=[
1302 Image(
1303 src=email.sender_image,
1304 size="40px",
1305 radius="md" if email.sender_type == "org" else "full",
1306 frame=True,
1307 ),
1308 Col(
1309 children=[
1310 Row(
1311 align="start",
1312 children=[
1313 Col(
1314 children=[
1315 Text(
1316 value=email.sender,
1317 weight="semibold",
1318 color="emphasis",
1319 ),
1320 Text(
1321 value=email.subject,
1322 color="emphasis",
1323 size="sm",
1324 ),
1325 ]
1326 ),
1327 Spacer(),
1328 Text(
1329 value=email.sent_at or "",
1330 size="sm",
1331 color="secondary",
1332 ),
1333 ],
1334 ),
1335 Text(
1336 value=re.sub(r"\s+", " ", email.body)[:500],
1337 size="sm",
1338 color="secondary",
1339 maxLines=2,
1340 ),
1341 ]
1342 ),
1343 ],
1344 )
1345 for email in state.emails
1346 ],
1347 )
1348
1349
1350# TASK TEMPLATES
1351
1352
1353def render_task_draft(id: str, state: TaskDraft):
1354 disabled = state.collapsed or state.status_text == "Creating task"
1355
1356 return Card(
1357 key="tasks.draft",
1358 size="lg",
1359 padding=0,
1360 status=state.status(),
1361 collapsed=state.collapsed,
1362 asForm=True,
1363 confirm={
1364 "label": "Create task",
1365 "action": CreateTaskAction.create(
1366 CreateTaskPayload(todo=state.todo, widget_id=id),
1367 loading_behavior="self",
1368 ),
1369 }
1370 if not state.collapsed
1371 else None,
1372 cancel={
1373 "label": "Cancel",
1374 "action": CancelTaskAction.create(BasePayload(widget_id=id)),
1375 }
1376 if not state.collapsed
1377 else None,
1378 children=[
1379 Col(
1380 padding=4,
1381 gap=2,
1382 children=[
1383 Text(
1384 editable={"name": "todo.title", "required": True}
1385 if not disabled
1386 else False,
1387 value=state.todo.title,
1388 weight="semibold",
1389 color="emphasis",
1390 size="lg",
1391 ),
1392 Text(
1393 value=state.todo.description,
1394 color="emphasis",
1395 minLines=6,
1396 editable={"name": "todo.description", "autoFocus": True}
1397 if not disabled
1398 else False,
1399 ),
1400 ],
1401 ),
1402 Col(
1403 padding={"x": 4, "y": 3.5},
1404 background="surface-secondary",
1405 border={"top": {"size": 1, "color": "subtle"}},
1406 children=[
1407 Row(
1408 gap=2,
1409 children=[
1410 Row(
1411 gap=2,
1412 width="fit-content",
1413 wrap="wrap",
1414 children=[
1415 Select(
1416 name="todo.priority",
1417 disabled=disabled,
1418 defaultValue=state.todo.priority,
1419 pill=True,
1420 options=[
1421 {"value": "low", "label": "Low priority"},
1422 {"value": "high", "label": "High priority"},
1423 ],
1424 ),
1425 Select(
1426 name="todo.timeframe",
1427 disabled=disabled,
1428 defaultValue=state.todo.timeframe,
1429 onChangeAction=UpdateDraftTaskAction.create(
1430 UpdateDraftTaskPayload(
1431 todo=state.todo, widget_id=id
1432 )
1433 ),
1434 pill=True,
1435 options=[
1436 {"value": "today", "label": "Due today"},
1437 {
1438 "value": "tomorrow",
1439 "label": "Due tomorrow",
1440 },
1441 {
1442 "value": "week",
1443 "label": "Due by end of week",
1444 },
1445 {
1446 "value": "month",
1447 "label": "Due by end of month",
1448 },
1449 {
1450 "value": "custom",
1451 "label": "Specific date",
1452 },
1453 ],
1454 ),
1455 *(
1456 [
1457 Row(
1458 gap=2,
1459 children=[
1460 Text(
1461 value="Due by",
1462 size="sm",
1463 color="secondary",
1464 ),
1465 DatePicker(
1466 name="todo.due_date",
1467 defaultValue=state.todo.due_date,
1468 pill=True,
1469 disabled=disabled,
1470 ),
1471 ],
1472 )
1473 ]
1474 if state.todo.timeframe == "custom"
1475 else []
1476 ),
1477 ],
1478 ),
1479 ],
1480 ),
1481 ],
1482 ),
1483 ],
1484 )
1485
1486
1487def render_task_view(id: str, state: TaskView):
1488 header: list[WidgetComponent] = (
1489 [
1490 Row(
1491 margin={"x": -2, "top": -2, "bottom": -1},
1492 children=[
1493 Button(
1494 onClickAction=ViewTasksAction.create(
1495 BasePayload(widget_id=id), loading_behavior="container"
1496 ),
1497 label="Back",
1498 color="secondary",
1499 variant="ghost",
1500 iconStart="chevron-left",
1501 size="xs",
1502 pill=True,
1503 ),
1504 ],
1505 ),
1506 Divider(flush=True),
1507 ]
1508 if state.show_back_button
1509 else []
1510 )
1511
1512 body = [
1513 Col(
1514 gap=1,
1515 children=[
1516 Text(
1517 value=f"{state.task.priority} priority",
1518 color=state.task.priority_color(),
1519 size="sm",
1520 weight="medium",
1521 ),
1522 Title(
1523 color="emphasis",
1524 value=state.task.title,
1525 weight="semibold",
1526 size="lg",
1527 ),
1528 Row(
1529 align="center",
1530 height=26,
1531 children=[
1532 Text(
1533 key="task.due_date",
1534 value=state.task.humanized_due_date(),
1535 color="tertiary",
1536 size="sm",
1537 )
1538 ],
1539 )
1540 if not state.task.completed
1541 else Row(
1542 key="task.completed",
1543 gap=1,
1544 height=22,
1545 margin={"top": 1},
1546 justify="start",
1547 width="fit-content",
1548 padding={"left": 1, "right": 2},
1549 background="blue-50",
1550 radius="full",
1551 children=[
1552 Icon(name="check-circle-filled", color="blue-400"),
1553 Text(
1554 value="Complete",
1555 color="blue-400",
1556 size="xs",
1557 weight="semibold",
1558 ),
1559 ],
1560 ),
1561 ],
1562 ),
1563 Divider(flush=True),
1564 Text(value=state.task.description, minLines=6),
1565 ]
1566
1567 footer = [
1568 Transition(
1569 children=Col(
1570 align="start",
1571 children=[
1572 Button(
1573 onClickAction=ToggleTaskCompleteAction.create(
1574 ToggleTaskCompletePayload(
1575 task_id=state.task.id, widget_id=id
1576 )
1577 ),
1578 label="Mark complete",
1579 color="secondary",
1580 variant="outline",
1581 iconStart="check-circle",
1582 pill=True,
1583 ),
1584 ],
1585 )
1586 if not state.task.completed
1587 else None
1588 )
1589 ]
1590
1591 return Card(
1592 key="tasks.view",
1593 size="lg",
1594 status=state.status(),
1595 children=[Col(gap=3, children=header + body + footer)],
1596 )
1597
1598
1599def render_tasks_list(id: str, state: TasksList):
1600 return ListView(
1601 key="tasks.list",
1602 status=state.status(),
1603 limit=5,
1604 children=[
1605 back_button_list_item(
1606 ShowWidgetAction.create(ShowWidgetPayload(widget="tasks", widget_id=id))
1607 ),
1608 ]
1609 + [
1610 ListViewItem(
1611 onClickAction=ViewTaskAction.create(
1612 ViewTaskPayload(task_id=task.id, widget_id=id)
1613 ),
1614 children=[
1615 Col(
1616 children=[
1617 Row(
1618 gap=3,
1619 children=[
1620 Button(
1621 size="3xs",
1622 variant=task.completed and "solid" or "outline",
1623 color=task.completed and "info" or "primary",
1624 iconStart=task.completed and "check" or None,
1625 uniform=True,
1626 pill=True,
1627 iconSize="lg",
1628 label="",
1629 onClickAction=ToggleTaskCompleteAction.create(
1630 ToggleTaskCompletePayload(
1631 task_id=task.id, widget_id=id
1632 )
1633 ),
1634 ),
1635 Text(
1636 value=task.title,
1637 weight="medium",
1638 color="emphasis",
1639 ),
1640 ],
1641 ),
1642 Transition(
1643 children=Row(
1644 padding={"left": 8.5},
1645 children=[
1646 Text(
1647 value=task.humanized_due_date(),
1648 size="sm",
1649 weight="medium",
1650 color=task.urgency_color(),
1651 ),
1652 ],
1653 )
1654 if not task.completed
1655 else None,
1656 ),
1657 ],
1658 ),
1659 ],
1660 )
1661 for task in state.tasks
1662 ],
1663 )
1664
1665
1666# CALENDAR TEMPLATES
1667
1668
1669def render_calendar_event_draft(id: str, state: CalendarEventDraft):
1670 return Card(
1671 key="calendar.draft",
1672 status=state.status(),
1673 collapsed=state.collapsed,
1674 confirm={
1675 "label": "Add to calendar",
1676 "action": CreateEventAction.create(
1677 CreateEventPayload(event=state.event, widget_id=id),
1678 loading_behavior="self",
1679 ),
1680 }
1681 if not state.collapsed
1682 else None,
1683 cancel={
1684 "label": "Discard",
1685 "action": DiscardEventAction.create(BasePayload(widget_id=id)),
1686 }
1687 if not state.collapsed
1688 else None,
1689 children=[
1690 Row(
1691 align="stretch",
1692 justify="stretch",
1693 children=[
1694 Col(
1695 width="64px",
1696 children=[
1697 Text(
1698 value="Wed",
1699 size="xs",
1700 color="tertiary",
1701 weight="semibold",
1702 ),
1703 Title(
1704 value="16",
1705 size="xl",
1706 color="emphasis",
1707 weight="semibold",
1708 ),
1709 ],
1710 ),
1711 Col(
1712 gap=2,
1713 flex="auto",
1714 children=[
1715 Row(
1716 padding=2,
1717 background="surface-tertiary",
1718 radius="md",
1719 align="stretch",
1720 gap=3,
1721 children=[
1722 Box(background="red-400", width=4, radius="full"),
1723 Col(
1724 children=[
1725 Text(value="Lunch", weight="medium"),
1726 Text(
1727 value="12:00 - 12:45 PM",
1728 size="xs",
1729 color="secondary",
1730 ),
1731 ],
1732 ),
1733 ],
1734 ),
1735 Row(
1736 padding=2,
1737 border={"style": "dashed", "size": 1},
1738 radius="md",
1739 align="stretch",
1740 gap=3,
1741 children=[
1742 Box(
1743 background=state.event.calendar_color(),
1744 width=4,
1745 radius="full",
1746 ),
1747 Col(
1748 children=[
1749 Text(
1750 value=state.event.title, weight="medium"
1751 ),
1752 Text(
1753 value=state.event.time(),
1754 size="xs",
1755 color="secondary",
1756 ),
1757 ],
1758 ),
1759 ],
1760 ),
1761 Row(
1762 padding=2,
1763 background="surface-tertiary",
1764 radius="md",
1765 align="stretch",
1766 gap=3,
1767 children=[
1768 Box(background="red-400", width=4, radius="full"),
1769 Col(
1770 children=[
1771 Text(value="Team standup", weight="medium"),
1772 Text(
1773 value="3:30 - 4:00 PM",
1774 size="xs",
1775 color="secondary",
1776 ),
1777 ],
1778 ),
1779 ],
1780 ),
1781 ],
1782 ),
1783 ],
1784 ),
1785 ],
1786 )
1787
1788
1789def render_calendar_event_view(id: str, state: CalendarEventView):
1790 back_button_row: list[WidgetComponent] = (
1791 [
1792 Row(
1793 margin={"x": -2, "top": -2, "bottom": -2},
1794 children=[
1795 Button(
1796 onClickAction=ViewScheduleAction.create(
1797 BasePayload(widget_id=id),
1798 loading_behavior="container",
1799 ),
1800 label="Back",
1801 color="secondary",
1802 variant="ghost",
1803 iconStart="chevron-left",
1804 size="xs",
1805 pill=True,
1806 ),
1807 ],
1808 ),
1809 Divider(flush=True),
1810 ]
1811 if state.show_back_button
1812 else []
1813 )
1814
1815 return Card(
1816 key="calendar.view",
1817 size="lg",
1818 status=state.status(),
1819 children=[
1820 Col(
1821 gap=4,
1822 children=back_button_row
1823 + [
1824 Col(
1825 gap=1,
1826 children=[
1827 Row(
1828 gap=2,
1829 children=[
1830 Box(
1831 radius="full",
1832 background=state.event.calendar_color(),
1833 size=8,
1834 ),
1835 Text(
1836 value=state.event.calendar,
1837 size="sm",
1838 color="emphasis",
1839 weight="medium",
1840 ),
1841 ],
1842 ),
1843 Text(value=state.event.title, size="xl", weight="semibold"),
1844 Row(
1845 gap=2,
1846 children=[
1847 Text(value=state.event.date, color="emphasis"),
1848 Text(
1849 value=state.event.time(),
1850 color="tertiary",
1851 ),
1852 ],
1853 ),
1854 ],
1855 ),
1856 Image(
1857 src=asset("map.png"),
1858 radius="sm",
1859 width="100%",
1860 height="230px",
1861 fit="cover",
1862 ),
1863 ],
1864 ),
1865 ],
1866 )
1867
1868
1869def render_calendar_events_list(id: str, state: CalendarEventsList):
1870 return ListView(
1871 key="calendar.list",
1872 status=state.status(),
1873 limit=5,
1874 children=[
1875 back_button_list_item(
1876 ShowWidgetAction.create(
1877 ShowWidgetPayload(widget="calendar", widget_id=id),
1878 loading_behavior="container",
1879 )
1880 )
1881 ]
1882 + [
1883 ListViewItem(
1884 onClickAction=ViewEventAction.create(
1885 ViewEventPayload(event_id=event.id, widget_id=id),
1886 loading_behavior="container",
1887 ),
1888 align="stretch",
1889 gap=4,
1890 children=[
1891 Box(radius="full", background=event.calendar_color(), width=4),
1892 Col(
1893 children=[
1894 Text(
1895 value=event.title,
1896 weight="medium",
1897 ),
1898 Text(
1899 value=event.time(),
1900 size="xs",
1901 color="secondary",
1902 ),
1903 ],
1904 ),
1905 ],
1906 )
1907 for event in state.events
1908 ],
1909 )
1910