#![allow(clippy::doc_markdown)]
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordLayoutRepresentation {
pub id: String,
pub layout_type: String,
pub mode: String,
pub sections: Vec<LayoutSection>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LayoutSection {
pub collapsed: bool,
pub columns: u32,
pub heading: Option<String>,
pub id: String,
pub layout_rows: Vec<LayoutRow>,
pub rows: u32,
pub use_heading: bool,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LayoutRow {
pub layout_items: Vec<LayoutItem>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LayoutItem {
pub field: Option<String>,
pub label: Option<String>,
pub required: bool,
pub sortable: bool,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
impl<A: crate::auth::Authenticator> crate::api::ui::UiHandler<A> {
pub async fn layout(
&self,
object: &str,
layout_type: Option<&crate::api::ui::types::LayoutType>,
mode: Option<&crate::api::ui::types::Mode>,
) -> crate::error::Result<RecordLayoutRepresentation> {
crate::types::validator::validate_sobject_name(object)?;
let path = format!("layout/{object}");
let mut params = [("", ""); 2];
let mut params_len = 0;
let lt_str;
if let Some(lt) = layout_type {
lt_str = lt.as_str();
params[params_len] = ("layoutType", lt_str);
params_len += 1;
}
let mode_str;
if let Some(m) = mode {
mode_str = m.as_str();
params[params_len] = ("mode", mode_str);
params_len += 1;
}
let query = if params_len == 0 {
None
} else {
Some(¶ms[..params_len])
};
self.get(&path, query, "Failed to fetch layout").await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::ui::types::{LayoutType, Mode};
use crate::client::builder;
use crate::test_support::{MockAuthenticator, Must};
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn make_client(server: &MockServer) -> crate::client::ForceClient<MockAuthenticator> {
let auth = MockAuthenticator::new("test_token", &server.uri());
builder().authenticate(auth).build().await.must()
}
fn minimal_layout_json() -> serde_json::Value {
json!({
"id": "layout-001",
"layoutType": "Full",
"mode": "View",
"sections": [
{
"collapsed": false,
"columns": 2,
"heading": "Account Information",
"id": "section-001",
"layoutRows": [
{
"layoutItems": [
{
"field": "Name",
"label": "Account Name",
"required": true,
"sortable": true
},
{
"field": "Phone",
"label": "Phone",
"required": false,
"sortable": false
}
]
}
],
"rows": 1,
"useHeading": true
}
]
})
}
#[tokio::test]
async fn test_layout_success_no_params() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path("/services/data/v60.0/ui-api/layout/Account"))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_layout_json()))
.expect(1)
.mount(&server)
.await;
let layout = client.ui().layout("Account", None, None).await.must();
assert_eq!(layout.id, "layout-001");
assert_eq!(layout.layout_type, "Full");
assert_eq!(layout.mode, "View");
assert_eq!(layout.sections.len(), 1);
let section = &layout.sections[0];
assert_eq!(section.id, "section-001");
assert_eq!(section.columns, 2);
assert!(!section.collapsed);
assert!(section.use_heading);
assert_eq!(section.heading.as_deref(), Some("Account Information"));
assert_eq!(section.layout_rows.len(), 1);
let row = §ion.layout_rows[0];
assert_eq!(row.layout_items.len(), 2);
let item = &row.layout_items[0];
assert_eq!(item.field.as_deref(), Some("Name"));
assert!(item.required);
assert!(item.sortable);
}
#[tokio::test]
async fn test_layout_with_layout_type_param() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path("/services/data/v60.0/ui-api/layout/Account"))
.and(query_param("layoutType", "Compact"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "layout-compact-001",
"layoutType": "Compact",
"mode": "View",
"sections": []
})))
.expect(1)
.mount(&server)
.await;
let layout = client
.ui()
.layout("Account", Some(&LayoutType::Compact), None)
.await
.must();
assert_eq!(layout.layout_type, "Compact");
}
#[tokio::test]
async fn test_layout_with_both_params() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path("/services/data/v60.0/ui-api/layout/Contact"))
.and(query_param("layoutType", "Full"))
.and(query_param("mode", "Edit"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "layout-edit-001",
"layoutType": "Full",
"mode": "Edit",
"sections": []
})))
.expect(1)
.mount(&server)
.await;
let layout = client
.ui()
.layout("Contact", Some(&LayoutType::Full), Some(&Mode::Edit))
.await
.must();
assert_eq!(layout.mode, "Edit");
assert_eq!(layout.layout_type, "Full");
}
#[tokio::test]
async fn test_layout_not_found() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path("/services/data/v60.0/ui-api/layout/NoSuchObject"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!([{
"errorCode": "NOT_FOUND",
"message": "The requested resource does not exist"
}])))
.expect(1)
.mount(&server)
.await;
let result = client.ui().layout("NoSuchObject", None, None).await;
let Err(err) = result else {
panic!("Expected an error");
};
assert!(
matches!(
err,
crate::error::ForceError::Api(_) | crate::error::ForceError::Http(_)
),
"Expected Api or Http error, got: {err}"
);
}
#[test]
fn test_record_layout_representation_deserialize() {
let json_str = r#"{
"id": "layout-test-001",
"layoutType": "Full",
"mode": "View",
"sections": [
{
"collapsed": false,
"columns": 1,
"heading": null,
"id": "section-a",
"layoutRows": [
{
"layoutItems": [
{
"field": "Name",
"label": "Name",
"required": true,
"sortable": false
}
]
}
],
"rows": 1,
"useHeading": false
}
]
}"#;
let layout: RecordLayoutRepresentation = serde_json::from_str(json_str).must();
assert_eq!(layout.id, "layout-test-001");
assert_eq!(layout.layout_type, "Full");
assert_eq!(layout.mode, "View");
assert_eq!(layout.sections.len(), 1);
let section = &layout.sections[0];
assert_eq!(section.columns, 1);
assert!(!section.collapsed);
assert!(!section.use_heading);
assert!(section.heading.is_none());
assert_eq!(section.rows, 1);
let item = §ion.layout_rows[0].layout_items[0];
assert_eq!(item.field.as_deref(), Some("Name"));
assert!(item.required);
assert!(!item.sortable);
}
#[tokio::test]
async fn test_layout_invalid_sobject_name() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let result = client.ui().layout("Account; DROP TABLE", None, None).await;
assert!(result.is_err());
assert!(
match result {
Err(e) => e,
Ok(_) => panic!("Expected error"),
}
.to_string()
.contains("contains invalid characters")
);
}
}