Skip to main content

lark_webhook_notify/
blocks.rs

1//! Low-level block types and layout enums for assembling Lark card JSON.
2//!
3//! All block structs implement [`Default`] and `From<T> for serde_json::Value`.
4//! The smart constructor functions (e.g. [`markdown`], [`column`]) fill required
5//! fields and leave everything else at its default:
6//!
7//! ```
8//! use lark_webhook_notify::blocks::{Markdown, TextAlign, TextSize, markdown};
9//!
10//! // Quick path — use the constructor:
11//! let v: serde_json::Value = markdown("**hello**").into();
12//!
13//! // Full control — use struct update syntax:
14//! let v: serde_json::Value = Markdown {
15//!     content: "**hello**".into(),
16//!     text_align: TextAlign::Center,
17//!     ..Default::default()
18//! }.into();
19//! ```
20
21use serde_json::{Value, json};
22
23/// Horizontal text alignment for markdown blocks.
24#[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/// Text size for markdown blocks.
49///
50/// `NormalV2` maps to `"normal_v2"` and is used in column cells to produce
51/// slightly larger text on mobile via the card config.
52#[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/// Horizontal alignment for column sets and columns.
79#[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/// Vertical alignment for column elements.
104#[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/// Background style for column sets.
129///
130/// `Grey100` → `"grey-100"`, `Plain` → `"default"` (no background tint).
131#[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/// Width mode for a column within a column set.
154///
155/// `Auto` sizes to content; `Weighted` distributes remaining space proportionally
156/// using the column's `weight` field.
157#[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
179/// A markdown text block (`"tag": "markdown"`).
180///
181/// Use the [`markdown`] constructor for the common case. Margin is a CSS
182/// shorthand string e.g. `"0px 0px 8px 0px"`.
183pub 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
220/// A colored text-tag badge, used in card header `text_tag_list`.
221pub struct TextTag {
222    pub text: String,
223    /// Lark color name, e.g. `"wathet"`, `"green"`, `"red"`.
224    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
244/// Card header block.
245///
246/// `template` is the Lark color name string (e.g. `"green"`, `"red"`).
247/// Prefer using [`CardBuilder::header`] which accepts [`ColorTheme`](crate::ColorTheme)
248/// and handles the color-name mapping automatically.
249pub 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}