1use serde_json::{Value, json};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum TextAlign {
26 #[default]
27 Left,
28 Center,
29 Right,
30}
31
32impl TextAlign {
33 pub fn as_str(&self) -> &'static str {
34 match self {
35 TextAlign::Left => "left",
36 TextAlign::Center => "center",
37 TextAlign::Right => "right",
38 }
39 }
40}
41
42impl std::fmt::Display for TextAlign {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 write!(f, "{}", self.as_str())
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum TextSize {
54 #[default]
55 Normal,
56 NormalV2,
57 Heading,
58 SmallHeading,
59}
60
61impl TextSize {
62 pub fn as_str(&self) -> &'static str {
63 match self {
64 TextSize::Normal => "normal",
65 TextSize::NormalV2 => "normal_v2",
66 TextSize::Heading => "heading",
67 TextSize::SmallHeading => "small_heading",
68 }
69 }
70}
71
72impl std::fmt::Display for TextSize {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 write!(f, "{}", self.as_str())
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum HAlign {
81 #[default]
82 Left,
83 Center,
84 Right,
85}
86
87impl HAlign {
88 pub fn as_str(&self) -> &'static str {
89 match self {
90 HAlign::Left => "left",
91 HAlign::Center => "center",
92 HAlign::Right => "right",
93 }
94 }
95}
96
97impl std::fmt::Display for HAlign {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 write!(f, "{}", self.as_str())
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum VAlign {
106 #[default]
107 Top,
108 Center,
109 Bottom,
110}
111
112impl VAlign {
113 pub fn as_str(&self) -> &'static str {
114 match self {
115 VAlign::Top => "top",
116 VAlign::Center => "center",
117 VAlign::Bottom => "bottom",
118 }
119 }
120}
121
122impl std::fmt::Display for VAlign {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 write!(f, "{}", self.as_str())
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum BgStyle {
133 #[default]
134 Grey100,
135 Plain,
136}
137
138impl BgStyle {
139 pub fn as_str(&self) -> &'static str {
140 match self {
141 BgStyle::Grey100 => "grey-100",
142 BgStyle::Plain => "default",
143 }
144 }
145}
146
147impl std::fmt::Display for BgStyle {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 write!(f, "{}", self.as_str())
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
158pub enum ColumnWidth {
159 #[default]
160 Auto,
161 Weighted,
162}
163
164impl ColumnWidth {
165 pub fn as_str(&self) -> &'static str {
166 match self {
167 ColumnWidth::Auto => "auto",
168 ColumnWidth::Weighted => "weighted",
169 }
170 }
171}
172
173impl std::fmt::Display for ColumnWidth {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 write!(f, "{}", self.as_str())
176 }
177}
178
179pub struct Markdown {
184 pub content: String,
185 pub text_align: TextAlign,
186 pub text_size: TextSize,
187 pub margin: String,
188}
189
190impl Default for Markdown {
191 fn default() -> Self {
192 Self {
193 content: String::new(),
194 text_align: TextAlign::Left,
195 text_size: TextSize::Normal,
196 margin: "0px 0px 0px 0px".into(),
197 }
198 }
199}
200
201impl From<Markdown> for Value {
202 fn from(m: Markdown) -> Value {
203 json!({
204 "tag": "markdown",
205 "content": m.content,
206 "text_align": m.text_align.as_str(),
207 "text_size": m.text_size.as_str(),
208 "margin": m.margin,
209 })
210 }
211}
212
213pub fn markdown(content: impl Into<String>) -> Markdown {
214 Markdown {
215 content: content.into(),
216 ..Default::default()
217 }
218}
219
220pub struct TextTag {
222 pub text: String,
223 pub color: String,
225}
226
227impl From<TextTag> for Value {
228 fn from(t: TextTag) -> Value {
229 json!({
230 "tag": "text_tag",
231 "text": {"tag": "plain_text", "content": t.text},
232 "color": t.color,
233 })
234 }
235}
236
237pub fn text_tag(text: impl Into<String>, color: impl Into<String>) -> TextTag {
238 TextTag {
239 text: text.into(),
240 color: color.into(),
241 }
242}
243
244pub struct HeaderBlock {
250 pub title: String,
251 pub template: String,
252 pub subtitle: Option<String>,
253 pub text_tag_list: Option<Vec<Value>>,
254 pub padding: Option<String>,
255}
256
257impl Default for HeaderBlock {
258 fn default() -> Self {
259 Self {
260 title: String::new(),
261 template: "blue".into(),
262 subtitle: None,
263 text_tag_list: None,
264 padding: None,
265 }
266 }
267}
268
269impl From<HeaderBlock> for Value {
270 fn from(h: HeaderBlock) -> Value {
271 let mut v = json!({
272 "title": {"tag": "plain_text", "content": h.title},
273 "template": h.template,
274 });
275 if let Some(s) = h.subtitle {
276 v["subtitle"] = json!({"tag": "plain_text", "content": s});
277 }
278 if let Some(tags) = h.text_tag_list {
279 v["text_tag_list"] = json!(tags);
280 }
281 if let Some(p) = h.padding {
282 v["padding"] = json!(p);
283 }
284 v
285 }
286}
287
288pub fn header(
289 title: impl Into<String>,
290 template: impl Into<String>,
291 subtitle: Option<impl Into<String>>,
292 text_tag_list: Option<Vec<Value>>,
293 padding: Option<impl Into<String>>,
294) -> HeaderBlock {
295 HeaderBlock {
296 title: title.into(),
297 template: template.into(),
298 subtitle: subtitle.map(|s| s.into()),
299 text_tag_list,
300 padding: padding.map(|p| p.into()),
301 }
302}
303
304pub struct Column {
305 pub elements: Vec<Value>,
306 pub width: ColumnWidth,
307 pub vertical_spacing: String,
308 pub h_align: HAlign,
309 pub v_align: VAlign,
310 pub weight: Option<u32>,
311}
312
313impl Default for Column {
314 fn default() -> Self {
315 Self {
316 elements: Vec::new(),
317 width: ColumnWidth::Auto,
318 vertical_spacing: "8px".into(),
319 h_align: HAlign::Left,
320 v_align: VAlign::Top,
321 weight: None,
322 }
323 }
324}
325
326impl From<Column> for Value {
327 fn from(c: Column) -> Value {
328 let mut v = json!({
329 "tag": "column",
330 "width": c.width.as_str(),
331 "elements": c.elements,
332 "vertical_spacing": c.vertical_spacing,
333 "horizontal_align": c.h_align.as_str(),
334 "vertical_align": c.v_align.as_str(),
335 });
336 if let Some(w) = c.weight {
337 v["weight"] = json!(w);
338 }
339 v
340 }
341}
342
343pub fn column(elements: Vec<Value>) -> Column {
344 Column {
345 elements,
346 ..Default::default()
347 }
348}
349
350pub struct ColumnSet {
351 pub columns: Vec<Value>,
352 pub bg_style: BgStyle,
353 pub h_spacing: String,
354 pub h_align: HAlign,
355 pub margin: String,
356}
357
358impl Default for ColumnSet {
359 fn default() -> Self {
360 Self {
361 columns: Vec::new(),
362 bg_style: BgStyle::Grey100,
363 h_spacing: "12px".into(),
364 h_align: HAlign::Left,
365 margin: "0px 0px 0px 0px".into(),
366 }
367 }
368}
369
370impl From<ColumnSet> for Value {
371 fn from(cs: ColumnSet) -> Value {
372 json!({
373 "tag": "column_set",
374 "background_style": cs.bg_style.as_str(),
375 "horizontal_spacing": cs.h_spacing,
376 "horizontal_align": cs.h_align.as_str(),
377 "columns": cs.columns,
378 "margin": cs.margin,
379 })
380 }
381}
382
383pub fn column_set(columns: Vec<Value>) -> ColumnSet {
384 ColumnSet {
385 columns,
386 ..Default::default()
387 }
388}
389
390pub struct CollapsiblePanel {
391 pub title_markdown: String,
392 pub elements: Vec<Value>,
393 pub expanded: bool,
394 pub bg_color: String,
395 pub border_color: String,
396 pub corner_radius: String,
397 pub vertical_spacing: String,
398 pub padding: String,
399}
400
401impl Default for CollapsiblePanel {
402 fn default() -> Self {
403 Self {
404 title_markdown: String::new(),
405 elements: Vec::new(),
406 expanded: false,
407 bg_color: "grey-200".into(),
408 border_color: "grey".into(),
409 corner_radius: "5px".into(),
410 vertical_spacing: "8px".into(),
411 padding: "8px 8px 8px 8px".into(),
412 }
413 }
414}
415
416impl From<CollapsiblePanel> for Value {
417 fn from(p: CollapsiblePanel) -> Value {
418 json!({
419 "tag": "collapsible_panel",
420 "expanded": p.expanded,
421 "header": {
422 "title": {"tag": "markdown", "content": p.title_markdown},
423 "background_color": p.bg_color,
424 "vertical_align": "center",
425 "icon": {
426 "tag": "standard_icon",
427 "token": "down-small-ccm_outlined",
428 "color": "",
429 "size": "16px 16px",
430 },
431 "icon_position": "right",
432 "icon_expanded_angle": -180,
433 },
434 "border": {"color": p.border_color, "corner_radius": p.corner_radius},
435 "vertical_spacing": p.vertical_spacing,
436 "padding": p.padding,
437 "elements": p.elements,
438 })
439 }
440}
441
442pub fn collapsible_panel(
443 title_markdown: impl Into<String>,
444 elements: Vec<Value>,
445) -> CollapsiblePanel {
446 CollapsiblePanel {
447 title_markdown: title_markdown.into(),
448 elements,
449 ..Default::default()
450 }
451}
452
453pub struct TemplateReference {
454 pub template_id: String,
455 pub version_name: String,
456 pub variables: Value,
457}
458
459impl From<TemplateReference> for Value {
460 fn from(t: TemplateReference) -> Value {
461 json!({
462 "type": "template",
463 "data": {
464 "template_id": t.template_id,
465 "template_version_name": t.version_name,
466 "template_variable": t.variables,
467 }
468 })
469 }
470}
471
472pub fn template_reference(
473 template_id: impl Into<String>,
474 version_name: impl Into<String>,
475 variables: Value,
476) -> TemplateReference {
477 TemplateReference {
478 template_id: template_id.into(),
479 version_name: version_name.into(),
480 variables,
481 }
482}
483
484pub struct Card {
485 pub elements: Vec<Value>,
486 pub header: Value,
487 pub schema: String,
488 pub config: Option<Value>,
489}
490
491impl Default for Card {
492 fn default() -> Self {
493 Self {
494 elements: Vec::new(),
495 header: Value::Null,
496 schema: "2.0".into(),
497 config: None,
498 }
499 }
500}
501
502impl From<Card> for Value {
503 fn from(c: Card) -> Value {
504 let mut v = json!({
505 "schema": c.schema,
506 "body": {
507 "direction": "vertical",
508 "elements": c.elements,
509 },
510 "header": c.header,
511 });
512 if let Some(cfg) = c.config {
513 v["config"] = cfg;
514 }
515 v
516 }
517}
518
519pub fn card(elements: Vec<Value>, header: Value) -> Card {
520 Card {
521 elements,
522 header,
523 ..Default::default()
524 }
525}
526
527pub fn config_textsize_normal_v2() -> Value {
528 json!({
529 "update_multi": true,
530 "style": {
531 "text_size": {
532 "normal_v2": {
533 "default": "normal",
534 "pc": "normal",
535 "mobile": "heading",
536 }
537 }
538 }
539 })
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn test_markdown_defaults() {
548 let b: Value = markdown("hello").into();
549 assert_eq!(b["tag"], "markdown");
550 assert_eq!(b["content"], "hello");
551 assert_eq!(b["text_align"], "left");
552 assert_eq!(b["text_size"], "normal");
553 }
554
555 #[test]
556 fn test_header_required_only() {
557 let h: Value = header("Title", "blue", None::<&str>, None, None::<&str>).into();
558 assert_eq!(h["title"]["tag"], "plain_text");
559 assert_eq!(h["title"]["content"], "Title");
560 assert_eq!(h["template"], "blue");
561 assert!(h.get("subtitle").is_none());
562 assert!(h.get("text_tag_list").is_none());
563 assert!(h.get("padding").is_none());
564 }
565
566 #[test]
567 fn test_header_with_optionals() {
568 let tags = vec![Value::from(text_tag("running", "wathet"))];
569 let h: Value = header(
570 "T",
571 "green",
572 Some("sub"),
573 Some(tags),
574 Some("12px 8px 12px 8px"),
575 )
576 .into();
577 assert_eq!(h["subtitle"]["content"], "sub");
578 assert_eq!(h["text_tag_list"][0]["color"], "wathet");
579 assert_eq!(h["padding"], "12px 8px 12px 8px");
580 }
581
582 #[test]
583 fn test_card_without_config() {
584 let hdr: Value = header("H", "blue", None::<&str>, None, None::<&str>).into();
585 let c: Value = Card {
586 elements: vec![Value::from(markdown("body"))],
587 header: hdr,
588 ..Default::default()
589 }
590 .into();
591 assert_eq!(c["schema"], "2.0");
592 assert_eq!(c["body"]["direction"], "vertical");
593 assert_eq!(c["body"]["elements"].as_array().unwrap().len(), 1);
594 assert!(c.get("config").is_none());
595 }
596
597 #[test]
598 fn test_card_with_config() {
599 let hdr: Value = header("H", "blue", None::<&str>, None, None::<&str>).into();
600 let cfg = config_textsize_normal_v2();
601 let c: Value = Card {
602 elements: vec![],
603 header: hdr,
604 config: Some(cfg),
605 ..Default::default()
606 }
607 .into();
608 assert!(c.get("config").is_some());
609 }
610
611 #[test]
612 fn test_column_set() {
613 let col1: Value = Column {
614 elements: vec![],
615 ..Default::default()
616 }
617 .into();
618 let cs: Value = ColumnSet {
619 columns: vec![col1],
620 ..Default::default()
621 }
622 .into();
623 assert_eq!(cs["tag"], "column_set");
624 assert_eq!(cs["background_style"], "grey-100");
625 }
626
627 #[test]
628 fn test_template_reference() {
629 let tr: Value =
630 template_reference("tmpl_id", "1.0.0", serde_json::json!({"key": "val"})).into();
631 assert_eq!(tr["type"], "template");
632 assert_eq!(tr["data"]["template_id"], "tmpl_id");
633 assert_eq!(tr["data"]["template_version_name"], "1.0.0");
634 assert_eq!(tr["data"]["template_variable"]["key"], "val");
635 }
636
637 #[test]
638 fn test_text_align_as_str() {
639 assert_eq!(TextAlign::Left.as_str(), "left");
640 assert_eq!(TextAlign::Center.as_str(), "center");
641 assert_eq!(TextAlign::Right.as_str(), "right");
642 }
643
644 #[test]
645 fn test_text_size_as_str() {
646 assert_eq!(TextSize::Normal.as_str(), "normal");
647 assert_eq!(TextSize::NormalV2.as_str(), "normal_v2");
648 assert_eq!(TextSize::Heading.as_str(), "heading");
649 assert_eq!(TextSize::SmallHeading.as_str(), "small_heading");
650 }
651
652 #[test]
653 fn test_halign_as_str() {
654 assert_eq!(HAlign::Left.as_str(), "left");
655 assert_eq!(HAlign::Center.as_str(), "center");
656 assert_eq!(HAlign::Right.as_str(), "right");
657 }
658
659 #[test]
660 fn test_valign_as_str() {
661 assert_eq!(VAlign::Top.as_str(), "top");
662 assert_eq!(VAlign::Center.as_str(), "center");
663 assert_eq!(VAlign::Bottom.as_str(), "bottom");
664 }
665
666 #[test]
667 fn test_bg_style_as_str() {
668 assert_eq!(BgStyle::Grey100.as_str(), "grey-100");
669 assert_eq!(BgStyle::Plain.as_str(), "default");
670 }
671
672 #[test]
673 fn test_column_width_as_str() {
674 assert_eq!(ColumnWidth::Auto.as_str(), "auto");
675 assert_eq!(ColumnWidth::Weighted.as_str(), "weighted");
676 }
677
678 #[test]
679 fn test_markdown_struct_from() {
680 let v: Value = Markdown {
681 content: "hello".into(),
682 ..Default::default()
683 }
684 .into();
685 assert_eq!(v["tag"], "markdown");
686 assert_eq!(v["content"], "hello");
687 assert_eq!(v["text_align"], "left");
688 assert_eq!(v["text_size"], "normal");
689 assert_eq!(v["margin"], "0px 0px 0px 0px");
690 }
691
692 #[test]
693 fn test_markdown_struct_custom_align() {
694 let v: Value = Markdown {
695 content: "hi".into(),
696 text_align: TextAlign::Center,
697 text_size: TextSize::NormalV2,
698 ..Default::default()
699 }
700 .into();
701 assert_eq!(v["text_align"], "center");
702 assert_eq!(v["text_size"], "normal_v2");
703 }
704
705 #[test]
706 fn test_text_tag_struct_from() {
707 let v: Value = TextTag {
708 text: "running".into(),
709 color: "wathet".into(),
710 }
711 .into();
712 assert_eq!(v["tag"], "text_tag");
713 assert_eq!(v["text"]["tag"], "plain_text");
714 assert_eq!(v["text"]["content"], "running");
715 assert_eq!(v["color"], "wathet");
716 }
717
718 #[test]
719 fn test_header_block_struct_required_only() {
720 let v: Value = HeaderBlock {
721 title: "Title".into(),
722 template: "blue".into(),
723 ..Default::default()
724 }
725 .into();
726 assert_eq!(v["title"]["tag"], "plain_text");
727 assert_eq!(v["title"]["content"], "Title");
728 assert_eq!(v["template"], "blue");
729 assert!(v.get("subtitle").is_none());
730 assert!(v.get("text_tag_list").is_none());
731 assert!(v.get("padding").is_none());
732 }
733
734 #[test]
735 fn test_header_block_struct_with_optionals() {
736 let tag: Value = TextTag {
737 text: "ok".into(),
738 color: "green".into(),
739 }
740 .into();
741 let v: Value = HeaderBlock {
742 title: "T".into(),
743 template: "green".into(),
744 subtitle: Some("sub".into()),
745 text_tag_list: Some(vec![tag]),
746 padding: Some("12px 8px 12px 8px".into()),
747 }
748 .into();
749 assert_eq!(v["subtitle"]["content"], "sub");
750 assert_eq!(v["text_tag_list"][0]["color"], "green");
751 assert_eq!(v["padding"], "12px 8px 12px 8px");
752 }
753
754 #[test]
755 fn test_column_struct_from() {
756 let v: Value = Column {
757 elements: vec![],
758 ..Default::default()
759 }
760 .into();
761 assert_eq!(v["tag"], "column");
762 assert_eq!(v["width"], "auto");
763 assert_eq!(v["vertical_spacing"], "8px");
764 assert_eq!(v["horizontal_align"], "left");
765 assert_eq!(v["vertical_align"], "top");
766 assert!(v.get("weight").is_none());
767 }
768
769 #[test]
770 fn test_column_struct_weighted() {
771 let v: Value = Column {
772 elements: vec![],
773 width: ColumnWidth::Weighted,
774 weight: Some(2),
775 ..Default::default()
776 }
777 .into();
778 assert_eq!(v["width"], "weighted");
779 assert_eq!(v["weight"], 2);
780 }
781
782 #[test]
783 fn test_column_set_struct_from() {
784 let v: Value = ColumnSet {
785 columns: vec![],
786 ..Default::default()
787 }
788 .into();
789 assert_eq!(v["tag"], "column_set");
790 assert_eq!(v["background_style"], "grey-100");
791 assert_eq!(v["horizontal_spacing"], "12px");
792 assert_eq!(v["horizontal_align"], "left");
793 }
794
795 #[test]
796 fn test_collapsible_panel_struct_from() {
797 let v: Value = CollapsiblePanel {
798 title_markdown: "Details".into(),
799 elements: vec![],
800 ..Default::default()
801 }
802 .into();
803 assert_eq!(v["tag"], "collapsible_panel");
804 assert_eq!(v["expanded"], false);
805 assert_eq!(v["header"]["title"]["content"], "Details");
806 assert_eq!(v["border"]["corner_radius"], "5px");
807 }
808
809 #[test]
810 fn test_template_reference_struct_from() {
811 let v: Value = TemplateReference {
812 template_id: "tmpl_id".into(),
813 version_name: "1.0.0".into(),
814 variables: serde_json::json!({"k": "v"}),
815 }
816 .into();
817 assert_eq!(v["type"], "template");
818 assert_eq!(v["data"]["template_id"], "tmpl_id");
819 assert_eq!(v["data"]["template_version_name"], "1.0.0");
820 assert_eq!(v["data"]["template_variable"]["k"], "v");
821 }
822
823 #[test]
824 fn test_card_struct_from() {
825 let hdr: Value = HeaderBlock {
826 title: "H".into(),
827 template: "blue".into(),
828 ..Default::default()
829 }
830 .into();
831 let v: Value = Card {
832 elements: vec![],
833 header: hdr,
834 ..Default::default()
835 }
836 .into();
837 assert_eq!(v["schema"], "2.0");
838 assert_eq!(v["body"]["direction"], "vertical");
839 assert!(v.get("config").is_none());
840 }
841
842 #[test]
843 fn test_card_struct_with_config() {
844 let hdr: Value = HeaderBlock {
845 title: "H".into(),
846 template: "blue".into(),
847 ..Default::default()
848 }
849 .into();
850 let cfg = config_textsize_normal_v2();
851 let v: Value = Card {
852 elements: vec![],
853 header: hdr,
854 config: Some(cfg),
855 ..Default::default()
856 }
857 .into();
858 assert!(v.get("config").is_some());
859 }
860}