1use crate::{
2 ChartAnnotation, ChartPoint, GridRow, GridRowsWindow, GridState, InboxItem, JsInteropDispatch,
3 PubSubCommand, RuntimeCommand, RuntimeEvent, ServerMessage, StreamBatchOperation,
4 StreamPosition, Toast,
5};
6use std::collections::BTreeMap;
7use std::fmt;
8use uuid::Uuid;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct SessionId(String);
13
14impl SessionId {
15 pub fn new() -> Self {
17 Self(Uuid::new_v4().to_string())
18 }
19
20 pub(crate) fn from_string(value: String) -> Self {
21 Self(value)
22 }
23
24 pub fn as_str(&self) -> &str {
26 &self.0
27 }
28}
29
30impl Default for SessionId {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl fmt::Display for SessionId {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.write_str(&self.0)
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct Context {
45 session_id: SessionId,
46 target_dom_id: String,
47 route_path: String,
48 route_params: BTreeMap<String, String>,
49 tenant_id: Option<String>,
50 connected: bool,
51 render_after_event: bool,
52 render_cadence_override_ms: Option<u64>,
53 pushes: Vec<ServerMessage>,
54 pubsub: Vec<PubSubCommand>,
55 runtime: Vec<RuntimeCommand>,
56}
57
58impl Context {
59 pub fn new(target_dom_id: impl Into<String>) -> Self {
61 Self::new_with_session_id(SessionId::new(), target_dom_id)
62 }
63
64 pub(crate) fn new_with_session_id(
65 session_id: SessionId,
66 target_dom_id: impl Into<String>,
67 ) -> Self {
68 Self {
69 session_id,
70 target_dom_id: target_dom_id.into(),
71 route_path: "/".to_string(),
72 route_params: BTreeMap::new(),
73 tenant_id: None,
74 connected: false,
75 render_after_event: true,
76 render_cadence_override_ms: None,
77 pushes: Vec::new(),
78 pubsub: Vec::new(),
79 runtime: Vec::new(),
80 }
81 }
82
83 pub fn session_id(&self) -> &SessionId {
85 &self.session_id
86 }
87
88 pub fn target_dom_id(&self) -> &str {
90 &self.target_dom_id
91 }
92
93 pub fn route_path(&self) -> &str {
95 &self.route_path
96 }
97
98 pub fn route_params(&self) -> &BTreeMap<String, String> {
100 &self.route_params
101 }
102
103 pub fn route_param(&self, key: &str) -> Option<&str> {
105 self.route_params.get(key).map(String::as_str)
106 }
107
108 pub fn tenant_id(&self) -> Option<&str> {
110 self.tenant_id.as_deref()
111 }
112
113 pub fn set_tenant_id(&mut self, tenant_id: impl Into<String>) {
115 self.tenant_id = normalize_tenant_id(Some(tenant_id.into()));
116 }
117
118 pub fn set_tenant_id_optional(&mut self, tenant_id: Option<String>) {
120 self.tenant_id = normalize_tenant_id(tenant_id);
121 }
122
123 pub fn clear_tenant_id(&mut self) {
125 self.tenant_id = None;
126 }
127
128 pub(crate) fn set_route(&mut self, path: impl Into<String>, params: BTreeMap<String, String>) {
129 self.route_path = path.into();
130 self.route_params = params;
131 }
132
133 pub fn is_connected(&self) -> bool {
135 self.connected
136 }
137
138 pub fn set_connected(&mut self, connected: bool) {
140 self.connected = connected;
141 }
142
143 pub fn push_message(&mut self, message: ServerMessage) {
145 self.pushes.push(message);
146 }
147
148 pub fn subscribe(&mut self, topic: impl Into<String>) {
150 self.pubsub.push(PubSubCommand::Subscribe {
151 topic: topic.into(),
152 });
153 }
154
155 pub fn broadcast(&mut self, topic: impl Into<String>, messages: Vec<ServerMessage>) {
157 self.skip_render();
158 self.pubsub.push(PubSubCommand::Broadcast {
159 topic: topic.into(),
160 messages,
161 });
162 }
163
164 pub fn broadcast_message(&mut self, topic: impl Into<String>, message: ServerMessage) {
166 self.broadcast(topic, vec![message]);
167 }
168
169 pub fn schedule_once(
171 &mut self,
172 id: impl Into<String>,
173 delay_ms: u64,
174 event: impl Into<String>,
175 value: impl Into<serde_json::Value>,
176 ) {
177 self.skip_render();
178 self.runtime.push(RuntimeCommand::ScheduleOnce {
179 id: id.into(),
180 delay_ms,
181 dispatch: RuntimeEvent::new(event, value),
182 });
183 }
184
185 pub fn schedule_once_to(
187 &mut self,
188 id: impl Into<String>,
189 delay_ms: u64,
190 target: impl Into<String>,
191 event: impl Into<String>,
192 value: impl Into<serde_json::Value>,
193 ) {
194 self.skip_render();
195 self.runtime.push(RuntimeCommand::ScheduleOnce {
196 id: id.into(),
197 delay_ms,
198 dispatch: RuntimeEvent::with_target(target, event, value),
199 });
200 }
201
202 pub fn schedule_interval(
204 &mut self,
205 id: impl Into<String>,
206 every_ms: u64,
207 event: impl Into<String>,
208 value: impl Into<serde_json::Value>,
209 ) {
210 self.skip_render();
211 self.runtime.push(RuntimeCommand::ScheduleInterval {
212 id: id.into(),
213 every_ms,
214 dispatch: RuntimeEvent::new(event, value),
215 });
216 }
217
218 pub fn schedule_interval_to(
220 &mut self,
221 id: impl Into<String>,
222 every_ms: u64,
223 target: impl Into<String>,
224 event: impl Into<String>,
225 value: impl Into<serde_json::Value>,
226 ) {
227 self.skip_render();
228 self.runtime.push(RuntimeCommand::ScheduleInterval {
229 id: id.into(),
230 every_ms,
231 dispatch: RuntimeEvent::with_target(target, event, value),
232 });
233 }
234
235 pub fn cancel_schedule(&mut self, id: impl Into<String>) {
237 self.skip_render();
238 self.runtime.push(RuntimeCommand::Cancel { id: id.into() });
239 }
240
241 pub fn request_render(&mut self) {
243 self.render_after_event = true;
244 }
245
246 pub fn set_render_cadence_ms(&mut self, cadence_ms: u64) {
250 self.render_cadence_override_ms = Some(cadence_ms);
251 }
252
253 pub fn clear_render_cadence_override(&mut self) {
255 self.render_cadence_override_ms = None;
256 }
257
258 pub fn skip_render(&mut self) {
260 self.render_after_event = false;
261 }
262
263 pub fn redirect(&mut self, to: impl Into<String>) {
265 self.push_message(ServerMessage::Redirect { to: to.into() });
266 }
267
268 pub fn patch_url(&mut self, to: impl Into<String>) {
270 self.push_message(ServerMessage::PatchUrl { to: to.into() });
271 }
272
273 pub fn navigate(&mut self, to: impl Into<String>) {
275 self.push_message(ServerMessage::Navigate { to: to.into() });
276 }
277
278 pub fn stream_insert(
280 &mut self,
281 target: impl Into<String>,
282 id: impl Into<String>,
283 html: impl Into<String>,
284 at: StreamPosition,
285 ) {
286 self.skip_render();
287 self.push_message(ServerMessage::StreamInsert {
288 target: target.into(),
289 id: id.into(),
290 html: html.into(),
291 at,
292 });
293 }
294
295 pub fn stream_append(
297 &mut self,
298 target: impl Into<String>,
299 id: impl Into<String>,
300 html: impl Into<String>,
301 ) {
302 self.stream_insert(target, id, html, StreamPosition::Append);
303 }
304
305 pub fn stream_prepend(
307 &mut self,
308 target: impl Into<String>,
309 id: impl Into<String>,
310 html: impl Into<String>,
311 ) {
312 self.stream_insert(target, id, html, StreamPosition::Prepend);
313 }
314
315 pub fn stream_delete(&mut self, target: impl Into<String>, id: impl Into<String>) {
317 self.skip_render();
318 self.push_message(ServerMessage::StreamDelete {
319 target: target.into(),
320 id: id.into(),
321 });
322 }
323
324 pub fn stream_batch(
326 &mut self,
327 target: impl Into<String>,
328 operations: Vec<StreamBatchOperation>,
329 ) {
330 if operations.is_empty() {
331 return;
332 }
333 self.skip_render();
334 self.push_message(ServerMessage::StreamBatch {
335 target: target.into(),
336 operations,
337 });
338 }
339
340 pub fn stream_append_many(&mut self, target: impl Into<String>, items: Vec<(String, String)>) {
342 if items.is_empty() {
343 return;
344 }
345 self.stream_batch(
346 target,
347 items
348 .into_iter()
349 .map(|(id, html)| StreamBatchOperation::Insert {
350 id,
351 html,
352 at: StreamPosition::Append,
353 })
354 .collect(),
355 );
356 }
357
358 pub fn stream_prepend_many(&mut self, target: impl Into<String>, items: Vec<(String, String)>) {
360 if items.is_empty() {
361 return;
362 }
363 self.stream_batch(
364 target,
365 items
366 .into_iter()
367 .map(|(id, html)| StreamBatchOperation::Insert {
368 id,
369 html,
370 at: StreamPosition::Prepend,
371 })
372 .collect(),
373 );
374 }
375
376 pub fn chart_series_append(
378 &mut self,
379 chart: impl Into<String>,
380 series: impl Into<String>,
381 point: ChartPoint,
382 ) {
383 self.skip_render();
384 self.push_message(ServerMessage::ChartSeriesAppend {
385 chart: chart.into(),
386 series: series.into(),
387 point,
388 });
389 }
390
391 pub fn chart_series_append_many(
393 &mut self,
394 chart: impl Into<String>,
395 series: impl Into<String>,
396 points: Vec<ChartPoint>,
397 ) {
398 if points.is_empty() {
399 return;
400 }
401 self.skip_render();
402 self.push_message(ServerMessage::ChartSeriesAppendMany {
403 chart: chart.into(),
404 series: series.into(),
405 points,
406 });
407 }
408
409 pub fn chart_series_replace(
411 &mut self,
412 chart: impl Into<String>,
413 series: impl Into<String>,
414 points: Vec<ChartPoint>,
415 ) {
416 self.skip_render();
417 self.push_message(ServerMessage::ChartSeriesReplace {
418 chart: chart.into(),
419 series: series.into(),
420 points,
421 });
422 }
423
424 pub fn chart_reset(&mut self, chart: impl Into<String>) {
426 self.skip_render();
427 self.push_message(ServerMessage::ChartReset {
428 chart: chart.into(),
429 });
430 }
431
432 pub fn chart_annotation_upsert(
434 &mut self,
435 chart: impl Into<String>,
436 annotation: ChartAnnotation,
437 ) {
438 self.skip_render();
439 self.push_message(ServerMessage::ChartAnnotationUpsert {
440 chart: chart.into(),
441 annotation,
442 });
443 }
444
445 pub fn chart_annotation_delete(&mut self, chart: impl Into<String>, id: impl Into<String>) {
447 self.skip_render();
448 self.push_message(ServerMessage::ChartAnnotationDelete {
449 chart: chart.into(),
450 id: id.into(),
451 });
452 }
453
454 pub fn toast_push(&mut self, toast: Toast) {
456 self.skip_render();
457 self.push_message(ServerMessage::ToastPush { toast });
458 }
459
460 pub fn toast_dismiss(&mut self, id: impl Into<String>) {
462 self.skip_render();
463 self.push_message(ServerMessage::ToastDismiss { id: id.into() });
464 }
465
466 pub fn inbox_upsert(&mut self, item: InboxItem) {
468 self.skip_render();
469 self.push_message(ServerMessage::InboxUpsert { item });
470 }
471
472 pub fn inbox_delete(&mut self, id: impl Into<String>) {
474 self.skip_render();
475 self.push_message(ServerMessage::InboxDelete { id: id.into() });
476 }
477
478 pub fn grid_replace(&mut self, grid: impl Into<String>, state: GridState) {
480 self.skip_render();
481 self.push_message(ServerMessage::GridReplace {
482 grid: grid.into(),
483 state,
484 });
485 }
486
487 pub fn grid_rows_replace(
489 &mut self,
490 grid: impl Into<String>,
491 offset: usize,
492 total_rows: usize,
493 rows: Vec<GridRow>,
494 ) {
495 self.skip_render();
496 self.push_message(ServerMessage::GridRowsReplace {
497 grid: grid.into(),
498 window: GridRowsWindow {
499 offset,
500 total_rows,
501 rows,
502 },
503 });
504 }
505
506 pub fn interop_dispatch(
508 &mut self,
509 event: impl Into<String>,
510 detail: impl Into<serde_json::Value>,
511 ) {
512 self.interop_dispatch_to(None::<String>, event, detail);
513 }
514
515 pub fn interop_dispatch_to(
517 &mut self,
518 target: impl Into<Option<String>>,
519 event: impl Into<String>,
520 detail: impl Into<serde_json::Value>,
521 ) {
522 self.skip_render();
523 self.push_message(ServerMessage::InteropDispatch {
524 dispatch: JsInteropDispatch {
525 target: target.into(),
526 event: event.into(),
527 detail: detail.into(),
528 bubbles: true,
529 },
530 });
531 }
532
533 pub fn webrtc_signal(&mut self, detail: impl Into<serde_json::Value>) {
535 self.interop_dispatch("shelly:webrtc-signal", detail);
536 }
537
538 pub fn webrtc_signal_to(
540 &mut self,
541 target: impl Into<String>,
542 detail: impl Into<serde_json::Value>,
543 ) {
544 self.interop_dispatch_to(Some(target.into()), "shelly:webrtc-signal", detail);
545 }
546
547 pub(crate) fn drain_pushes(&mut self) -> Vec<ServerMessage> {
548 std::mem::take(&mut self.pushes)
549 }
550
551 pub(crate) fn drain_pubsub_commands(&mut self) -> Vec<PubSubCommand> {
552 std::mem::take(&mut self.pubsub)
553 }
554
555 pub(crate) fn drain_runtime_commands(&mut self) -> Vec<RuntimeCommand> {
556 std::mem::take(&mut self.runtime)
557 }
558
559 pub(crate) fn schedule_internal_once(
560 &mut self,
561 id: impl Into<String>,
562 delay_ms: u64,
563 event: impl Into<String>,
564 value: impl Into<serde_json::Value>,
565 ) {
566 self.runtime.push(RuntimeCommand::ScheduleOnce {
567 id: id.into(),
568 delay_ms,
569 dispatch: RuntimeEvent::new(event, value),
570 });
571 }
572
573 pub(crate) fn cancel_schedule_internal(&mut self, id: impl Into<String>) {
574 self.runtime.push(RuntimeCommand::Cancel { id: id.into() });
575 }
576
577 pub(crate) fn take_render_after_event(&mut self) -> bool {
578 let render = self.render_after_event;
579 self.render_after_event = true;
580 render
581 }
582
583 pub(crate) fn render_cadence_override_ms(&self) -> Option<u64> {
584 self.render_cadence_override_ms
585 }
586}
587
588fn normalize_tenant_id(tenant_id: Option<String>) -> Option<String> {
589 tenant_id
590 .map(|value| value.trim().to_string())
591 .filter(|value| !value.is_empty())
592}
593
594#[cfg(test)]
595mod tests {
596 use super::Context;
597 use crate::{
598 ChartAnnotation, ChartPoint, GridColumn, GridPinned, GridRow, GridRowsWindow,
599 GridSavedView, GridSort, GridSortDirection, GridState, InboxItem, JsInteropDispatch,
600 PubSubCommand, RuntimeCommand, RuntimeEvent, ServerMessage, StreamBatchOperation,
601 StreamPosition, Toast, ToastLevel,
602 };
603 use serde_json::json;
604
605 #[test]
606 fn context_can_queue_redirects() {
607 let mut ctx = Context::new("root");
608 ctx.redirect("/next");
609
610 assert_eq!(
611 ctx.drain_pushes(),
612 vec![ServerMessage::Redirect {
613 to: "/next".to_string()
614 }]
615 );
616 }
617
618 #[test]
619 fn context_tracks_route_params() {
620 let mut ctx = Context::new("root");
621 ctx.set_route(
622 "/pages/intro",
623 [("slug".to_string(), "intro".to_string())].into(),
624 );
625
626 assert_eq!(ctx.route_path(), "/pages/intro");
627 assert_eq!(ctx.route_param("slug"), Some("intro"));
628 assert_eq!(ctx.route_param("missing"), None);
629 }
630
631 #[test]
632 fn context_tracks_tenant_context() {
633 let mut ctx = Context::new("root");
634 assert_eq!(ctx.tenant_id(), None);
635
636 ctx.set_tenant_id("tenant-a");
637 assert_eq!(ctx.tenant_id(), Some("tenant-a"));
638
639 ctx.set_tenant_id_optional(Some(" ".to_string()));
640 assert_eq!(ctx.tenant_id(), None);
641
642 ctx.set_tenant_id_optional(Some("tenant-b".to_string()));
643 assert_eq!(ctx.tenant_id(), Some("tenant-b"));
644
645 ctx.clear_tenant_id();
646 assert_eq!(ctx.tenant_id(), None);
647 }
648
649 #[test]
650 fn context_can_queue_navigation_messages() {
651 let mut ctx = Context::new("root");
652 ctx.patch_url("/pages/intro");
653 ctx.navigate("/users/1");
654
655 assert_eq!(
656 ctx.drain_pushes(),
657 vec![
658 ServerMessage::PatchUrl {
659 to: "/pages/intro".to_string()
660 },
661 ServerMessage::Navigate {
662 to: "/users/1".to_string()
663 },
664 ]
665 );
666 }
667
668 #[test]
669 fn context_can_queue_stream_messages_without_root_render() {
670 let mut ctx = Context::new("root");
671 ctx.stream_append("messages", "msg-1", "<li id=\"msg-1\">hi</li>");
672 ctx.stream_prepend("messages", "msg-0", "<li id=\"msg-0\">first</li>");
673 ctx.stream_delete("messages", "msg-1");
674
675 assert!(!ctx.take_render_after_event());
676 assert_eq!(
677 ctx.drain_pushes(),
678 vec![
679 ServerMessage::StreamInsert {
680 target: "messages".to_string(),
681 id: "msg-1".to_string(),
682 html: "<li id=\"msg-1\">hi</li>".to_string(),
683 at: StreamPosition::Append,
684 },
685 ServerMessage::StreamInsert {
686 target: "messages".to_string(),
687 id: "msg-0".to_string(),
688 html: "<li id=\"msg-0\">first</li>".to_string(),
689 at: StreamPosition::Prepend,
690 },
691 ServerMessage::StreamDelete {
692 target: "messages".to_string(),
693 id: "msg-1".to_string(),
694 },
695 ]
696 );
697 assert!(ctx.take_render_after_event());
698 }
699
700 #[test]
701 fn context_can_set_and_clear_render_cadence_override() {
702 let mut ctx = Context::new("root");
703 assert_eq!(ctx.render_cadence_override_ms(), None);
704
705 ctx.set_render_cadence_ms(24);
706 assert_eq!(ctx.render_cadence_override_ms(), Some(24));
707
708 ctx.clear_render_cadence_override();
709 assert_eq!(ctx.render_cadence_override_ms(), None);
710 }
711
712 #[test]
713 fn context_can_queue_stream_batch_messages_without_root_render() {
714 let mut ctx = Context::new("root");
715 ctx.stream_batch(
716 "messages",
717 vec![
718 StreamBatchOperation::Insert {
719 id: "msg-2".to_string(),
720 html: "<li id=\"msg-2\">batch</li>".to_string(),
721 at: StreamPosition::Append,
722 },
723 StreamBatchOperation::Delete {
724 id: "msg-1".to_string(),
725 },
726 ],
727 );
728
729 assert!(!ctx.take_render_after_event());
730 assert_eq!(
731 ctx.drain_pushes(),
732 vec![ServerMessage::StreamBatch {
733 target: "messages".to_string(),
734 operations: vec![
735 StreamBatchOperation::Insert {
736 id: "msg-2".to_string(),
737 html: "<li id=\"msg-2\">batch</li>".to_string(),
738 at: StreamPosition::Append,
739 },
740 StreamBatchOperation::Delete {
741 id: "msg-1".to_string(),
742 },
743 ],
744 }]
745 );
746 assert!(ctx.take_render_after_event());
747 }
748
749 #[test]
750 fn context_can_queue_pubsub_commands_without_root_render() {
751 let mut ctx = Context::new("root");
752 ctx.subscribe("chat:lobby");
753 ctx.broadcast_message(
754 "chat:lobby",
755 ServerMessage::StreamDelete {
756 target: "messages".to_string(),
757 id: "msg-1".to_string(),
758 },
759 );
760
761 assert!(!ctx.take_render_after_event());
762 assert_eq!(
763 ctx.drain_pubsub_commands(),
764 vec![
765 PubSubCommand::Subscribe {
766 topic: "chat:lobby".to_string()
767 },
768 PubSubCommand::Broadcast {
769 topic: "chat:lobby".to_string(),
770 messages: vec![ServerMessage::StreamDelete {
771 target: "messages".to_string(),
772 id: "msg-1".to_string(),
773 }],
774 }
775 ]
776 );
777 }
778
779 #[test]
780 fn context_can_queue_runtime_commands_without_root_render() {
781 let mut ctx = Context::new("root");
782 ctx.schedule_once("retry-once", 250, "load_more", json!({"cursor": "a1"}));
783 ctx.schedule_interval_to(
784 "heartbeat",
785 1000,
786 "grid",
787 "tick",
788 json!({"source": "timer"}),
789 );
790 ctx.cancel_schedule("retry-once");
791
792 assert!(!ctx.take_render_after_event());
793 assert_eq!(
794 ctx.drain_runtime_commands(),
795 vec![
796 RuntimeCommand::ScheduleOnce {
797 id: "retry-once".to_string(),
798 delay_ms: 250,
799 dispatch: RuntimeEvent::new("load_more", json!({"cursor": "a1"})),
800 },
801 RuntimeCommand::ScheduleInterval {
802 id: "heartbeat".to_string(),
803 every_ms: 1000,
804 dispatch: RuntimeEvent::with_target("grid", "tick", json!({"source": "timer"}),),
805 },
806 RuntimeCommand::Cancel {
807 id: "retry-once".to_string(),
808 },
809 ]
810 );
811 assert!(ctx.take_render_after_event());
812 }
813
814 #[test]
815 fn context_can_queue_chart_stream_message_without_root_render() {
816 let mut ctx = Context::new("root");
817 ctx.chart_series_append("traffic", "requests", ChartPoint { x: 1.0, y: 2.5 });
818
819 assert!(!ctx.take_render_after_event());
820 assert_eq!(
821 ctx.drain_pushes(),
822 vec![ServerMessage::ChartSeriesAppend {
823 chart: "traffic".to_string(),
824 series: "requests".to_string(),
825 point: ChartPoint { x: 1.0, y: 2.5 },
826 }]
827 );
828 assert!(ctx.take_render_after_event());
829 }
830
831 #[test]
832 fn context_can_queue_chart_series_replace_and_reset_without_root_render() {
833 let mut ctx = Context::new("root");
834 ctx.chart_series_replace(
835 "traffic",
836 "requests",
837 vec![ChartPoint { x: 1.0, y: 2.5 }, ChartPoint { x: 2.0, y: 3.0 }],
838 );
839 ctx.chart_reset("traffic");
840
841 assert!(!ctx.take_render_after_event());
842 assert_eq!(
843 ctx.drain_pushes(),
844 vec![
845 ServerMessage::ChartSeriesReplace {
846 chart: "traffic".to_string(),
847 series: "requests".to_string(),
848 points: vec![ChartPoint { x: 1.0, y: 2.5 }, ChartPoint { x: 2.0, y: 3.0 }],
849 },
850 ServerMessage::ChartReset {
851 chart: "traffic".to_string(),
852 },
853 ]
854 );
855 assert!(ctx.take_render_after_event());
856 }
857
858 #[test]
859 fn context_can_queue_chart_append_many_without_root_render() {
860 let mut ctx = Context::new("root");
861 ctx.chart_series_append_many(
862 "traffic",
863 "requests",
864 vec![ChartPoint { x: 3.0, y: 4.0 }, ChartPoint { x: 4.0, y: 5.0 }],
865 );
866
867 assert!(!ctx.take_render_after_event());
868 assert_eq!(
869 ctx.drain_pushes(),
870 vec![ServerMessage::ChartSeriesAppendMany {
871 chart: "traffic".to_string(),
872 series: "requests".to_string(),
873 points: vec![ChartPoint { x: 3.0, y: 4.0 }, ChartPoint { x: 4.0, y: 5.0 }],
874 }]
875 );
876 assert!(ctx.take_render_after_event());
877 }
878
879 #[test]
880 fn context_can_queue_chart_annotation_messages_without_root_render() {
881 let mut ctx = Context::new("root");
882 ctx.chart_annotation_upsert(
883 "traffic",
884 ChartAnnotation {
885 id: "release-1".to_string(),
886 x: 18.0,
887 label: "Deploy".to_string(),
888 },
889 );
890 ctx.chart_annotation_delete("traffic", "release-1");
891
892 assert!(!ctx.take_render_after_event());
893 assert_eq!(
894 ctx.drain_pushes(),
895 vec![
896 ServerMessage::ChartAnnotationUpsert {
897 chart: "traffic".to_string(),
898 annotation: ChartAnnotation {
899 id: "release-1".to_string(),
900 x: 18.0,
901 label: "Deploy".to_string(),
902 },
903 },
904 ServerMessage::ChartAnnotationDelete {
905 chart: "traffic".to_string(),
906 id: "release-1".to_string(),
907 },
908 ]
909 );
910 assert!(ctx.take_render_after_event());
911 }
912
913 #[test]
914 fn context_can_queue_toast_and_inbox_messages_without_root_render() {
915 let mut ctx = Context::new("root");
916 ctx.toast_push(Toast {
917 id: "toast-1".to_string(),
918 level: ToastLevel::Success,
919 title: Some("Saved".to_string()),
920 message: "Profile updated".to_string(),
921 ttl_ms: Some(2000),
922 });
923 ctx.toast_dismiss("toast-1");
924 ctx.inbox_upsert(InboxItem {
925 id: "msg-1".to_string(),
926 title: "Welcome".to_string(),
927 body: "Thanks for joining".to_string(),
928 read: false,
929 inserted_at: None,
930 });
931 ctx.inbox_delete("msg-1");
932
933 assert!(!ctx.take_render_after_event());
934 assert_eq!(
935 ctx.drain_pushes(),
936 vec![
937 ServerMessage::ToastPush {
938 toast: Toast {
939 id: "toast-1".to_string(),
940 level: ToastLevel::Success,
941 title: Some("Saved".to_string()),
942 message: "Profile updated".to_string(),
943 ttl_ms: Some(2000),
944 },
945 },
946 ServerMessage::ToastDismiss {
947 id: "toast-1".to_string(),
948 },
949 ServerMessage::InboxUpsert {
950 item: InboxItem {
951 id: "msg-1".to_string(),
952 title: "Welcome".to_string(),
953 body: "Thanks for joining".to_string(),
954 read: false,
955 inserted_at: None,
956 },
957 },
958 ServerMessage::InboxDelete {
959 id: "msg-1".to_string(),
960 },
961 ]
962 );
963 assert!(ctx.take_render_after_event());
964 }
965
966 #[test]
967 fn context_can_queue_interop_and_webrtc_messages_without_root_render() {
968 let mut ctx = Context::new("root");
969 ctx.interop_dispatch_to(
970 Some("peer-a".to_string()),
971 "map:pan_to",
972 json!({"lat": 42.0, "lng": -71.0}),
973 );
974 ctx.webrtc_signal(json!({"kind": "offer", "sdp": "v=0..."}));
975
976 assert!(!ctx.take_render_after_event());
977 assert_eq!(
978 ctx.drain_pushes(),
979 vec![
980 ServerMessage::InteropDispatch {
981 dispatch: JsInteropDispatch {
982 target: Some("peer-a".to_string()),
983 event: "map:pan_to".to_string(),
984 detail: json!({"lat": 42.0, "lng": -71.0}),
985 bubbles: true,
986 },
987 },
988 ServerMessage::InteropDispatch {
989 dispatch: JsInteropDispatch {
990 target: None,
991 event: "shelly:webrtc-signal".to_string(),
992 detail: json!({"kind": "offer", "sdp": "v=0..."}),
993 bubbles: true,
994 },
995 },
996 ]
997 );
998 assert!(ctx.take_render_after_event());
999 }
1000
1001 #[test]
1002 fn context_can_queue_grid_replace_without_root_render() {
1003 let mut ctx = Context::new("root");
1004 ctx.grid_replace(
1005 "enterprise-grid",
1006 GridState {
1007 columns: vec![GridColumn {
1008 id: "name".to_string(),
1009 label: "Name".to_string(),
1010 width_px: Some(220),
1011 min_width_px: Some(100),
1012 pinned: GridPinned::Left,
1013 sortable: true,
1014 resizable: true,
1015 editable: false,
1016 }],
1017 rows: vec![GridRow {
1018 id: "row-1".to_string(),
1019 cells: std::iter::once(("name".to_string(), json!("Ada"))).collect(),
1020 group: Some("Engineering".to_string()),
1021 }],
1022 total_rows: 2000,
1023 offset: 0,
1024 limit: 200,
1025 views: vec![GridSavedView {
1026 id: "eng".to_string(),
1027 label: "Engineering".to_string(),
1028 }],
1029 active_view: Some("eng".to_string()),
1030 group_by: Some("department".to_string()),
1031 query: Some("ada".to_string()),
1032 sort: Some(GridSort {
1033 column: "name".to_string(),
1034 direction: GridSortDirection::Asc,
1035 }),
1036 },
1037 );
1038
1039 assert!(!ctx.take_render_after_event());
1040 assert_eq!(
1041 ctx.drain_pushes(),
1042 vec![ServerMessage::GridReplace {
1043 grid: "enterprise-grid".to_string(),
1044 state: GridState {
1045 columns: vec![GridColumn {
1046 id: "name".to_string(),
1047 label: "Name".to_string(),
1048 width_px: Some(220),
1049 min_width_px: Some(100),
1050 pinned: GridPinned::Left,
1051 sortable: true,
1052 resizable: true,
1053 editable: false,
1054 }],
1055 rows: vec![GridRow {
1056 id: "row-1".to_string(),
1057 cells: std::iter::once(("name".to_string(), json!("Ada"))).collect(),
1058 group: Some("Engineering".to_string()),
1059 }],
1060 total_rows: 2000,
1061 offset: 0,
1062 limit: 200,
1063 views: vec![GridSavedView {
1064 id: "eng".to_string(),
1065 label: "Engineering".to_string(),
1066 }],
1067 active_view: Some("eng".to_string()),
1068 group_by: Some("department".to_string()),
1069 query: Some("ada".to_string()),
1070 sort: Some(GridSort {
1071 column: "name".to_string(),
1072 direction: GridSortDirection::Asc,
1073 }),
1074 },
1075 }]
1076 );
1077 assert!(ctx.take_render_after_event());
1078 }
1079
1080 #[test]
1081 fn context_can_queue_grid_window_replace_without_root_render() {
1082 let mut ctx = Context::new("root");
1083 ctx.grid_rows_replace(
1084 "enterprise-grid",
1085 400,
1086 1000,
1087 vec![GridRow {
1088 id: "acct-401".to_string(),
1089 cells: serde_json::Map::from_iter([
1090 ("name".to_string(), json!("Acme North")),
1091 ("arr".to_string(), json!(166000)),
1092 ]),
1093 group: Some("Enterprise".to_string()),
1094 }],
1095 );
1096
1097 assert!(!ctx.take_render_after_event());
1098 assert_eq!(
1099 ctx.drain_pushes(),
1100 vec![ServerMessage::GridRowsReplace {
1101 grid: "enterprise-grid".to_string(),
1102 window: GridRowsWindow {
1103 offset: 400,
1104 total_rows: 1000,
1105 rows: vec![GridRow {
1106 id: "acct-401".to_string(),
1107 cells: serde_json::Map::from_iter([
1108 ("name".to_string(), json!("Acme North")),
1109 ("arr".to_string(), json!(166000)),
1110 ]),
1111 group: Some("Enterprise".to_string()),
1112 }],
1113 },
1114 }]
1115 );
1116 assert!(ctx.take_render_after_event());
1117 }
1118}