1use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17
18#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase", default)]
24pub struct StudioConfig {
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub brand: Option<BrandConfig>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub theme: Option<ThemeConfig>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub sidebar: Option<SidebarConfig>,
31 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32 pub resources: BTreeMap<String, ResourceConfig>,
33 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
34 pub pages: BTreeMap<String, PageConfig>,
35 #[serde(default)]
39 pub has_extensions: bool,
40 #[serde(skip_serializing_if = "Option::is_none")]
45 pub login_url: Option<String>,
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase", default)]
54pub struct BrandConfig {
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub name: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub logo: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub subtitle: Option<String>,
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase", default)]
69pub struct ThemeConfig {
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub accent: Option<ThemeAccent>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub appearance: Option<ThemeAppearance>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub primary: Option<String>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum ThemeAccent {
81 Emerald,
82 Blue,
83 Violet,
84 Rose,
85 Amber,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum ThemeAppearance {
91 Dark,
92 Light,
93 System,
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase", default)]
102pub struct SidebarConfig {
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub sections: Vec<SidebarSection>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub footer: Option<SidebarFooter>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub org_switcher: Option<OrgSwitcherConfig>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub collapsible: Option<bool>,
111}
112
113#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase", default)]
115pub struct SidebarSection {
116 pub label: String,
117 #[serde(default)]
118 pub items: Vec<SidebarItem>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub default_open: Option<bool>,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(tag = "type", rename_all = "lowercase")]
126pub enum SidebarItem {
127 Page(SidebarPageItem),
128 Resource(SidebarResourceItem),
129 Link(SidebarLinkItem),
130 Heading(SidebarHeadingItem),
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase", default)]
135pub struct SidebarPageItem {
136 pub id: String,
137 pub label: String,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub icon: Option<String>,
140 #[serde(default, skip_serializing_if = "is_false")]
141 pub requires_admin: bool,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub requires_roles: Vec<String>,
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase", default)]
148pub struct SidebarResourceItem {
149 pub entity: String,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub label: Option<String>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub icon: Option<String>,
154 #[serde(default, skip_serializing_if = "is_false")]
155 pub requires_admin: bool,
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub requires_roles: Vec<String>,
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase", default)]
162pub struct SidebarLinkItem {
163 pub label: String,
164 pub href: String,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub icon: Option<String>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub external: Option<bool>,
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase", default)]
173pub struct SidebarHeadingItem {
174 pub label: String,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178#[serde(tag = "type", rename_all = "lowercase")]
179pub enum SidebarFooter {
180 Card(SidebarFooterCard),
181 Custom(SidebarFooterCustom),
182}
183
184#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase", default)]
186pub struct SidebarFooterCard {
187 pub title: String,
188 pub description: String,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub action: Option<FooterAction>,
191 #[serde(skip_serializing_if = "Option::is_none")]
194 pub progress: Option<f64>,
195}
196
197#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase", default)]
199pub struct FooterAction {
200 pub label: String,
201 pub href: String,
202}
203
204#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase", default)]
206pub struct SidebarFooterCustom {
207 pub component_id: String,
208}
209
210#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase", default)]
212pub struct OrgSwitcherConfig {
213 pub items: Vec<OrgSwitcherItem>,
214}
215
216#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase", default)]
218pub struct OrgSwitcherItem {
219 pub id: String,
220 pub label: String,
221}
222
223#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase", default)]
229pub struct ResourceConfig {
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub label: Option<String>,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub plural_label: Option<String>,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub icon: Option<String>,
236 #[serde(default, skip_serializing_if = "is_false")]
237 pub hidden: bool,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub list: Option<ResourceListConfig>,
240}
241
242#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase", default)]
244pub struct ResourceListConfig {
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub columns: Vec<ColumnConfig>,
247 #[serde(skip_serializing_if = "Option::is_none")]
248 pub searchable: Option<bool>,
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub filterable: Option<bool>,
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub bulk_actions: Vec<BulkAction>,
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub row_actions: Vec<RowAction>,
255 #[serde(skip_serializing_if = "Option::is_none")]
256 pub default_sort: Option<DefaultSort>,
257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub page_sizes: Vec<u32>,
259 #[serde(skip_serializing_if = "Option::is_none")]
260 pub default_page_size: Option<u32>,
261}
262
263#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
264#[serde(rename_all = "camelCase", default)]
265pub struct DefaultSort {
266 pub field: String,
267 pub order: SortOrder,
268}
269
270#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
271#[serde(rename_all = "lowercase")]
272pub enum SortOrder {
273 #[default]
274 Asc,
275 Desc,
276}
277
278#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase", default)]
280pub struct ColumnConfig {
281 pub field: String,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub label: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub order: Option<i32>,
286 #[serde(default, skip_serializing_if = "is_false")]
287 pub hidden: bool,
288 #[serde(default, skip_serializing_if = "is_false")]
289 pub sortable: bool,
290 #[serde(default, skip_serializing_if = "is_false")]
291 pub searchable: bool,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 pub filterable: Option<ColumnFilterable>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub renderer: Option<ColumnRenderer>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 pub width: Option<String>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub align: Option<ColumnAlign>,
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303#[serde(untagged)]
304pub enum ColumnFilterable {
305 Bool(bool),
306 Spec(ColumnFilterSpec),
307}
308
309#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase", default)]
311pub struct ColumnFilterSpec {
312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 pub options: Vec<FilterOption>,
314}
315
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320pub struct FilterOption {
321 pub label: String,
322 pub value: serde_json::Value,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
326#[serde(rename_all = "lowercase")]
327pub enum ColumnAlign {
328 Left,
329 Center,
330 Right,
331}
332
333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
338#[serde(tag = "kind", rename_all = "lowercase")]
339pub enum ColumnRenderer {
340 Text(RendererText),
341 Avatar(RendererAvatar),
342 Badge(RendererBadge),
343 Date(RendererDate),
344 Link(RendererLink),
345 Boolean(RendererBoolean),
346 Number(RendererNumber),
347 Json(RendererJson),
348 Custom(RendererCustom),
349}
350
351#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase", default)]
353pub struct RendererText {
354 #[serde(skip_serializing_if = "Option::is_none")]
355 pub truncate: Option<u32>,
356 #[serde(default, skip_serializing_if = "is_false")]
357 pub mono: bool,
358}
359
360#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase", default)]
362pub struct RendererAvatar {
363 #[serde(skip_serializing_if = "Option::is_none")]
364 pub image_field: Option<String>,
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub subtitle_field: Option<String>,
367 #[serde(skip_serializing_if = "Option::is_none")]
368 pub name_field: Option<String>,
369}
370
371#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase", default)]
373pub struct RendererBadge {
374 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
375 pub variants: BTreeMap<String, BadgeVariant>,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 pub dot: Option<bool>,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
381#[serde(rename_all = "lowercase")]
382pub enum BadgeVariant {
383 Green,
384 Red,
385 Amber,
386 Blue,
387 Gray,
388}
389
390#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase", default)]
392pub struct RendererDate {
393 #[serde(skip_serializing_if = "Option::is_none")]
394 pub format: Option<DateFormat>,
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
398#[serde(rename_all = "lowercase")]
399pub enum DateFormat {
400 Relative,
401 Absolute,
402 Iso,
403}
404
405#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
406#[serde(rename_all = "camelCase", default)]
407pub struct RendererLink {
408 pub href: String,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub external: Option<bool>,
411}
412
413#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
414#[serde(rename_all = "camelCase", default)]
415pub struct RendererBoolean {
416 #[serde(skip_serializing_if = "Option::is_none")]
417 pub true_label: Option<String>,
418 #[serde(skip_serializing_if = "Option::is_none")]
419 pub false_label: Option<String>,
420}
421
422#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
423#[serde(rename_all = "camelCase", default)]
424pub struct RendererNumber {
425 #[serde(skip_serializing_if = "Option::is_none")]
426 pub style: Option<NumberStyle>,
427 #[serde(skip_serializing_if = "Option::is_none")]
428 pub currency: Option<String>,
429 #[serde(skip_serializing_if = "Option::is_none")]
430 pub locale: Option<String>,
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
434#[serde(rename_all = "lowercase")]
435pub enum NumberStyle {
436 Decimal,
437 Percent,
438 Currency,
439 Bytes,
440}
441
442#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
443#[serde(rename_all = "camelCase", default)]
444pub struct RendererJson {
445 #[serde(skip_serializing_if = "Option::is_none")]
446 pub truncate: Option<u32>,
447}
448
449#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
450#[serde(rename_all = "camelCase", default)]
451pub struct RendererCustom {
452 pub component_id: String,
453}
454
455#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
460#[serde(rename_all = "camelCase", default)]
461pub struct BulkAction {
462 pub id: String,
463 pub label: String,
464 #[serde(skip_serializing_if = "Option::is_none")]
465 pub icon: Option<String>,
466 #[serde(skip_serializing_if = "Option::is_none")]
467 pub kind: Option<BulkActionKind>,
468 #[serde(skip_serializing_if = "Option::is_none")]
469 pub confirm: Option<String>,
470 #[serde(default, skip_serializing_if = "is_false")]
471 pub requires_admin: bool,
472}
473
474#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
475#[serde(rename_all = "lowercase")]
476pub enum BulkActionKind {
477 Delete,
478 Export,
479 Custom,
480}
481
482#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase", default)]
484pub struct RowAction {
485 pub id: String,
486 pub label: String,
487 #[serde(skip_serializing_if = "Option::is_none")]
488 pub icon: Option<String>,
489 #[serde(skip_serializing_if = "Option::is_none")]
490 pub kind: Option<RowActionKind>,
491 #[serde(skip_serializing_if = "Option::is_none")]
492 pub confirm: Option<String>,
493 #[serde(default, skip_serializing_if = "is_false")]
494 pub requires_admin: bool,
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
498#[serde(rename_all = "lowercase")]
499pub enum RowActionKind {
500 Delete,
501 Edit,
502 View,
503 Custom,
504}
505
506#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
511#[serde(rename_all = "camelCase", default)]
512pub struct PageConfig {
513 #[serde(skip_serializing_if = "Option::is_none")]
514 pub subtitle: Option<String>,
515 #[serde(skip_serializing_if = "Option::is_none")]
516 pub component_id: Option<String>,
517}
518
519fn is_false(b: &bool) -> bool {
524 !*b
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn empty_config_round_trips() {
533 let cfg = StudioConfig::default();
534 let json = serde_json::to_string(&cfg).unwrap();
535 let back: StudioConfig = serde_json::from_str(&json).unwrap();
536 assert_eq!(cfg, back);
537 }
538
539 #[test]
540 fn parse_minimal_user_config() {
541 let json = r#"{
542 "brand": { "name": "Acme" },
543 "theme": { "accent": "emerald", "appearance": "dark" },
544 "sidebar": {
545 "sections": [{
546 "label": "RESOURCES",
547 "items": [
548 { "type": "resource", "entity": "User", "icon": "users" },
549 { "type": "page", "id": "overview", "label": "Overview" }
550 ]
551 }]
552 },
553 "resources": {
554 "User": {
555 "list": {
556 "columns": [
557 {
558 "field": "status",
559 "renderer": {
560 "kind": "badge",
561 "variants": { "active": "green", "blocked": "red" }
562 }
563 }
564 ]
565 }
566 }
567 }
568 }"#;
569 let cfg: StudioConfig = serde_json::from_str(json).unwrap();
570 assert_eq!(cfg.brand.unwrap().name.unwrap(), "Acme");
571 assert_eq!(cfg.theme.unwrap().accent.unwrap(), ThemeAccent::Emerald);
572 assert_eq!(cfg.sidebar.unwrap().sections.len(), 1);
573 let user = cfg.resources.get("User").unwrap();
574 let col = &user.list.as_ref().unwrap().columns[0];
575 match col.renderer.as_ref().unwrap() {
576 ColumnRenderer::Badge(b) => {
577 assert_eq!(b.variants.get("active"), Some(&BadgeVariant::Green));
578 assert_eq!(b.variants.get("blocked"), Some(&BadgeVariant::Red));
579 }
580 _ => panic!("expected Badge renderer"),
581 }
582 }
583
584 #[test]
585 fn parse_link_and_heading_items() {
586 let json = r#"{
587 "sidebar": {
588 "sections": [{
589 "label": "ACCOUNTS",
590 "items": [
591 { "type": "heading", "label": "External" },
592 { "type": "link", "label": "Google Analytics", "href": "https://analytics.google.com" }
593 ]
594 }]
595 }
596 }"#;
597 let cfg: StudioConfig = serde_json::from_str(json).unwrap();
598 let items = &cfg.sidebar.unwrap().sections[0].items;
599 assert!(matches!(items[0], SidebarItem::Heading(_)));
600 match &items[1] {
601 SidebarItem::Link(l) => {
602 assert_eq!(l.label, "Google Analytics");
603 assert_eq!(l.href, "https://analytics.google.com");
604 }
605 _ => panic!("expected Link"),
606 }
607 }
608}