use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InlineStyle {
Tabular,
Stacked,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InlineRowInfo {
pub id: Option<String>,
pub values: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InlineFormInfo {
pub key: String,
pub model_name: String,
pub style: InlineStyle,
pub fields: Vec<FieldInfo>,
pub rows: Vec<InlineRowInfo>,
#[serde(default)]
pub can_change: bool,
pub can_delete: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RelationWidget {
Autocomplete,
RawId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelationOption {
pub id: String,
pub label: String,
}
impl RelationOption {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ModelPermission {
View,
Add,
Change,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminAction {
pub name: String,
pub label: String,
pub permission: ModelPermission,
pub requires_confirmation: bool,
}
impl AdminAction {
pub fn new(
name: impl Into<String>,
label: impl Into<String>,
permission: ModelPermission,
requires_confirmation: bool,
) -> Self {
Self {
name: name.into(),
label: label.into(),
permission,
requires_confirmation,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminActionOutcome {
pub successful_ids: Vec<String>,
pub affected: u64,
}
impl AdminActionOutcome {
pub fn new(successful_ids: Vec<String>, affected: u64) -> Self {
Self {
successful_ids,
affected,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DateHierarchySelection {
pub year: Option<i32>,
pub month: Option<u32>,
pub day: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DateHierarchyLevel {
Year,
Month,
Day,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DateHierarchyInfo {
pub field: String,
pub selection: DateHierarchySelection,
pub next_level: Option<DateHierarchyLevel>,
pub choices: Vec<i32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationSelectorLayout {
Horizontal,
Vertical,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AdminWidget {
TextInput,
EmailInput,
NumberInput,
Checkbox,
DateInput,
DateTimeInput,
TextArea {
rows: Option<u16>,
},
Select {
choices: Vec<(String, String)>,
},
MultiSelect {
choices: Vec<(String, String)>,
},
Autocomplete,
RawId,
ManyToMany {
layout: RelationSelectorLayout,
},
FileInput,
HiddenInput,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormFieldOverride {
pub field: String,
pub widget: Option<AdminWidget>,
pub label: Option<String>,
pub help_text: Option<String>,
pub placeholder: Option<String>,
pub required: Option<bool>,
}
impl FormFieldOverride {
pub fn new(field: impl Into<String>) -> Self {
Self {
field: field.into(),
widget: None,
label: None,
help_text: None,
placeholder: None,
required: None,
}
}
pub fn widget(mut self, widget: AdminWidget) -> Self {
self.widget = Some(widget);
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn help_text(mut self, help_text: impl Into<String>) -> Self {
self.help_text = Some(help_text.into());
self
}
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
pub fn required(mut self, required: bool) -> Self {
self.required = Some(required);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrepopulatedField {
pub target: String,
pub sources: Vec<String>,
}
impl PrepopulatedField {
pub fn new<I, S>(target: impl Into<String>, sources: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
target: target.into(),
sources: sources.into_iter().map(Into::into).collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub name: String,
pub list_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldInfo {
pub name: String,
pub label: String,
pub field_type: FieldType,
pub required: bool,
#[serde(default)]
pub nullable: bool,
pub readonly: bool,
pub help_text: Option<String>,
pub placeholder: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fieldset {
pub title: Option<String>,
pub fields: Vec<String>,
#[serde(default)]
pub collapsed: bool,
}
impl Fieldset {
pub fn new(title: Option<&str>, fields: &[&str]) -> Self {
Self {
title: title.map(String::from),
fields: fields.iter().map(|field| String::from(*field)).collect(),
collapsed: false,
}
}
pub fn collapsed(mut self) -> Self {
self.collapsed = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "options")]
pub enum FieldType {
Text,
TextArea,
TextAreaWithRows {
rows: Option<u16>,
},
Number,
Boolean,
Email,
Date,
DateTime,
Select {
choices: Vec<(String, String)>,
},
MultiSelect {
choices: Vec<(String, String)>,
},
ManyToManySelector {
layout: RelationSelectorLayout,
available: Vec<RelationOption>,
selected: Vec<RelationOption>,
page: u64,
has_more: bool,
},
Relation {
field_name: String,
widget: RelationWidget,
selected: Option<RelationOption>,
#[serde(default)]
readonly: bool,
},
File,
Hidden,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
pub enum FormFieldSpec {
Input {
html_type: String,
},
TextArea,
TextAreaWithRows {
rows: Option<u16>,
},
Json,
Select {
choices: Vec<(String, String)>,
},
MultiSelect {
choices: Vec<(String, String)>,
},
ManyToManySelector {
layout: RelationSelectorLayout,
available: Vec<RelationOption>,
selected: Vec<RelationOption>,
page: u64,
has_more: bool,
},
Relation {
field_name: String,
widget: RelationWidget,
selected: Option<RelationOption>,
#[serde(default)]
readonly: bool,
},
File,
Hidden,
}
impl From<&FieldType> for FormFieldSpec {
fn from(field_type: &FieldType) -> Self {
match field_type {
FieldType::Text => FormFieldSpec::Input {
html_type: "text".to_string(),
},
FieldType::Number => FormFieldSpec::Input {
html_type: "number".to_string(),
},
FieldType::Boolean => FormFieldSpec::Input {
html_type: "checkbox".to_string(),
},
FieldType::Email => FormFieldSpec::Input {
html_type: "email".to_string(),
},
FieldType::Date => FormFieldSpec::Input {
html_type: "date".to_string(),
},
FieldType::DateTime => FormFieldSpec::Input {
html_type: "datetime-local".to_string(),
},
FieldType::TextArea => FormFieldSpec::TextArea,
FieldType::TextAreaWithRows { rows } => FormFieldSpec::TextAreaWithRows { rows: *rows },
FieldType::Select { choices } => FormFieldSpec::Select {
choices: choices.clone(),
},
FieldType::MultiSelect { choices } => FormFieldSpec::MultiSelect {
choices: choices.clone(),
},
FieldType::ManyToManySelector {
layout,
available,
selected,
page,
has_more,
} => FormFieldSpec::ManyToManySelector {
layout: *layout,
available: available.clone(),
selected: selected.clone(),
page: *page,
has_more: *has_more,
},
FieldType::Relation {
field_name,
widget,
selected,
readonly,
} => FormFieldSpec::Relation {
field_name: field_name.clone(),
widget: *widget,
selected: selected.clone(),
readonly: *readonly,
},
FieldType::File => FormFieldSpec::File,
FieldType::Hidden => FormFieldSpec::Hidden,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn textarea_unit_variants_keep_the_legacy_wire_shape() {
assert_eq!(
serde_json::to_value(FieldType::TextArea).expect("field type should serialize"),
json!({"type": "TextArea"})
);
assert_eq!(
serde_json::from_value::<FieldType>(json!({"type": "TextArea"}))
.expect("legacy field type should deserialize"),
FieldType::TextArea
);
assert_eq!(
serde_json::to_value(FormFieldSpec::TextArea).expect("form spec should serialize"),
json!({"kind": "TextArea"})
);
assert_eq!(
serde_json::from_value::<FormFieldSpec>(json!({"kind": "TextArea"}))
.expect("legacy form spec should deserialize"),
FormFieldSpec::TextArea
);
}
#[test]
fn many_to_many_selector_conversion_preserves_selector_data() {
let field_type = FieldType::ManyToManySelector {
layout: RelationSelectorLayout::Horizontal,
available: vec![RelationOption::new("1", "Rust")],
selected: vec![RelationOption::new("2", "WebAssembly")],
page: 3,
has_more: true,
};
assert_eq!(
FormFieldSpec::from(&field_type),
FormFieldSpec::ManyToManySelector {
layout: RelationSelectorLayout::Horizontal,
available: vec![RelationOption::new("1", "Rust")],
selected: vec![RelationOption::new("2", "WebAssembly")],
page: 3,
has_more: true,
}
);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "options")]
pub enum FilterType {
Boolean,
Choice {
choices: Vec<FilterChoice>,
},
DateRange {
ranges: Vec<FilterChoice>,
},
NumberRange {
ranges: Vec<FilterChoice>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FilterChoice {
pub value: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterInfo {
pub field: String,
pub title: String,
pub filter_type: FilterType,
pub current_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnInfo {
pub field: String,
pub label: String,
pub sortable: bool,
#[serde(default)]
pub editable: bool,
#[serde(default)]
pub linked: bool,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub nullable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub form_spec: Option<FormFieldSpec>,
}
#[cfg(all(test, server))]
mod relation_tests {
use super::*;
use rstest::rstest;
use serde_json::json;
#[rstest]
#[case(RelationWidget::Autocomplete, json!("autocomplete"))]
#[case(RelationWidget::RawId, json!("raw_id"))]
fn relation_widget_uses_stable_wire_values(
#[case] widget: RelationWidget,
#[case] expected: serde_json::Value,
) {
let serialized = serde_json::to_value(widget).expect("relation widget should serialize");
assert_eq!(serialized, expected);
}
#[rstest]
fn relation_option_round_trips() {
let option = RelationOption {
id: "42".to_string(),
label: "Ada Lovelace".to_string(),
};
let serialized = serde_json::to_value(&option).expect("relation option should serialize");
let deserialized: RelationOption =
serde_json::from_value(serialized.clone()).expect("relation option should deserialize");
assert_eq!(serialized, json!({"id": "42", "label": "Ada Lovelace"}));
assert_eq!(deserialized, option);
}
#[rstest]
fn relation_field_type_serializes_and_converts_without_losing_metadata() {
let selected = RelationOption {
id: "7".to_string(),
label: "Grace Hopper".to_string(),
};
let field_type = FieldType::Relation {
field_name: "author".to_string(),
widget: RelationWidget::Autocomplete,
selected: Some(selected.clone()),
readonly: false,
};
let serialized =
serde_json::to_value(&field_type).expect("relation field type should serialize");
let form_spec = FormFieldSpec::from(&field_type);
assert_eq!(
serialized,
json!({
"type": "Relation",
"options": {
"field_name": "author",
"widget": "autocomplete",
"selected": {"id": "7", "label": "Grace Hopper"},
"readonly": false
}
})
);
assert_eq!(
form_spec,
FormFieldSpec::Relation {
field_name: "author".to_string(),
widget: RelationWidget::Autocomplete,
selected: Some(selected),
readonly: false,
}
);
}
}