Skip to main content

pylon_kernel/
studio.rs

1//! Pylon Studio runtime configuration.
2//!
3//! This is the Rust mirror of `packages/sdk/src/studio.ts`. Authored by
4//! the user as `studio.config.ts`, compiled to JSON by the CLI, read at
5//! runtime, and injected into the Studio HTML as
6//! `window.__PYLON_STUDIO_CONFIG__`.
7//!
8//! Every field is optional — an empty config is valid, and the web shell
9//! falls back to a sensible default (manifest entities → resources,
10//! emerald accent, Used Space footer card).
11//!
12//! Wire format is camelCase JSON to match the TS authoring surface.
13//! `serde(default)` everywhere so partial configs round-trip cleanly.
14
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17
18// ---------------------------------------------------------------------------
19// Top-level
20// ---------------------------------------------------------------------------
21
22#[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    /// True when the project ships a `studio.entry.tsx` bundle. Studio
36    /// HTML dynamic-imports `/studio/extensions.js` only when this flag
37    /// is set.
38    #[serde(default)]
39    pub has_extensions: bool,
40    /// URL to redirect unauthenticated `/studio` callers to. Apps with
41    /// their own login page (Pylon Cloud, dashboards) point this at
42    /// their existing flow so users don't see the built-in admin-token
43    /// form. The framework appends `?next=/studio`.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub login_url: Option<String>,
46}
47
48// ---------------------------------------------------------------------------
49// Brand
50// ---------------------------------------------------------------------------
51
52#[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// ---------------------------------------------------------------------------
64// Theme
65// ---------------------------------------------------------------------------
66
67#[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// ---------------------------------------------------------------------------
97// Sidebar
98// ---------------------------------------------------------------------------
99
100#[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/// Discriminated union via `type` field. Matches the TS shape exactly.
124#[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    /// 0..1 progress fill. Stored as an `f64` so the JSON round-trips
192    /// the user's original number unchanged.
193    #[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// ---------------------------------------------------------------------------
224// Resources
225// ---------------------------------------------------------------------------
226
227#[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/// Filter option value is preserved as a `serde_json::Value` so any
317/// JSON shape (string, number, bool, null, object) round-trips intact.
318#[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// ---------------------------------------------------------------------------
334// Renderers — discriminated union on `kind`
335// ---------------------------------------------------------------------------
336
337#[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// ---------------------------------------------------------------------------
456// Bulk + row actions
457// ---------------------------------------------------------------------------
458
459#[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// ---------------------------------------------------------------------------
507// Pages
508// ---------------------------------------------------------------------------
509
510#[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
519// ---------------------------------------------------------------------------
520// helpers
521// ---------------------------------------------------------------------------
522
523fn 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}