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