#![allow(dead_code, private_interfaces)]
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::OnceCell;
use tower::ServiceExt;
use umbral_admin::{AdminModel, AdminPlugin};
use umbral_auth::{AuthPlugin, AuthUser, create_user};
use umbral_sessions::SessionsPlugin;
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
struct Note {
id: i64,
title: String,
#[umbral(widget = "markdown")]
body: String,
published: bool,
}
static BOOT: OnceCell<axum::Router> = OnceCell::const_new();
static NOTE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn boot() -> &'static axum::Router {
BOOT.get_or_init(|| async {
let settings =
umbral::Settings::from_env().expect("figment defaults always load in a test env");
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("phase2_sheet.sqlite");
std::mem::forget(tmp);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::new()
.busy_timeout(std::time::Duration::from_secs(5))
.filename(&path)
.create_if_missing(true),
)
.await
.expect("sqlite pool");
let note_config = AdminModel::new("note")
.list_display(&["title", "published"])
.search_fields(&["title", "body"]);
let app = umbral::App::builder()
.settings(settings)
.database("default", pool)
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(SessionsPlugin::default().without_auto_layer())
.plugin(AdminPlugin::default().register(note_config))
.model::<Note>()
.build()
.expect("App::build");
umbral::migrate::create_tables_for_tests()
.await
.expect("create the test schema");
let pool = umbral::db::pool();
let staff = create_user("sheet_admin", "sheet@example.com", "password123")
.await
.expect("create staff");
sqlx::query("UPDATE auth_user SET is_staff = 1 WHERE id = ?")
.bind(staff.id)
.execute(&pool)
.await
.expect("mark staff");
sqlx::query(
"INSERT INTO note (title, body, published) VALUES \
('Test Note', 'Original body', 1)",
)
.execute(&pool)
.await
.expect("seed note");
app.into_router()
})
.await
}
async fn send(
router: axum::Router,
req: Request<Body>,
) -> (StatusCode, axum::http::HeaderMap, String) {
let resp = router.oneshot(req).await.expect("oneshot");
let status = resp.status();
let headers = resp.headers().clone();
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
(
status,
headers,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
fn extract_csrf(html: &str) -> Option<String> {
let marker = r#"name="csrf_token""#;
let pos = html.find(marker)?;
let window = &html[pos..pos + 200];
let val_marker = r#"value=""#;
let vpos = window.find(val_marker)?;
let after = &window[vpos + val_marker.len()..];
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn extract_cookie_value(set_cookie: &str) -> String {
set_cookie
.split(';')
.next()
.and_then(|p| p.split_once('=').map(|(_, v)| v.to_string()))
.unwrap_or_default()
}
fn opening_tag_for<'a>(html: &'a str, tag_name: &str, needle: &str) -> &'a str {
let needle_pos = html.find(needle).unwrap_or_else(|| {
panic!(
"expected rendered HTML to contain `{needle}`; snippet={}",
&html[..html.len().min(1200)]
)
});
let tag_start = html[..needle_pos]
.rfind(&format!("<{tag_name}"))
.unwrap_or_else(|| panic!("expected `{needle}` to be inside a <{tag_name}> tag"));
let tag_end = html[needle_pos..]
.find('>')
.map(|offset| needle_pos + offset + 1)
.unwrap_or_else(|| panic!("unterminated <{tag_name}> tag around `{needle}`"));
&html[tag_start..tag_end]
}
fn rendered_tag_has_attr(tag: &str, attr_name: &str) -> bool {
tag.trim_matches(|c| c == '<' || c == '>')
.split_whitespace()
.skip(1)
.any(|part| part.split_once('=').map_or(part, |(name, _)| name) == attr_name)
}
async fn login_session(router: axum::Router, username: &str, password: &str) -> String {
let resp = router
.clone()
.oneshot(
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await
.expect("login get");
let anon_raw = resp
.headers()
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_default();
let anon_cookie = extract_cookie_value(&anon_raw);
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let html = String::from_utf8_lossy(&bytes).into_owned();
let csrf = extract_csrf(&html).unwrap_or_default();
let form_body = serde_urlencoded::to_string([
("username", username),
("password", password),
("csrf_token", csrf.as_str()),
("next", "/admin/"),
])
.unwrap();
let resp2 = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/login")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, format!("umbral_csrf_token={anon_cookie}"))
.body(Body::from(form_body))
.unwrap(),
)
.await
.expect("login post");
resp2
.headers()
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.map(extract_cookie_value)
.unwrap_or(anon_cookie)
}
async fn fresh_staff(router: axum::Router) -> String {
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let username = format!("staff_iso{n}");
let user = create_user(&username, &format!("{username}@test.com"), "password123")
.await
.expect("create staff user");
sqlx::query("UPDATE auth_user SET is_staff = 1 WHERE id = ?")
.bind(user.id)
.execute(&umbral::db::pool())
.await
.expect("mark staff");
login_session(router, &username, "password123").await
}
#[tokio::test]
async fn test_preview_sheet_htmx_returns_fragment() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let pool = umbral::db::pool();
sqlx::query("INSERT INTO note (title, body, published) VALUES ('PreviewFragmentNote', 'b', 1)")
.execute(&pool)
.await
.expect("seed preview note");
let id: i64 = sqlx::query_scalar("SELECT id FROM note WHERE title = 'PreviewFragmentNote'")
.fetch_one(&pool)
.await
.expect("preview note id");
let req = Request::builder()
.uri(format!("/admin/note/{id}/sheet"))
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, _headers, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("Preview") && body.contains("Edit"),
"Preview/Edit toggle present: snippet={}",
&body[..body.len().min(1000)]
);
assert!(!body.contains("<!doctype html>"), "not a full page: {body}");
assert!(
body.contains("PreviewFragmentNote"),
"record data present: {body}"
);
}
#[tokio::test]
async fn test_edit_sheet_htmx_returns_form_with_editors() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let req = Request::builder()
.uri("/admin/note/1/edit-sheet")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, _headers, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains(r#"<input"#) || body.contains(r#"<textarea"#),
"form inputs present: snippet={}",
&body[..body.len().min(1000)]
);
assert!(body.contains("title"), "title field present: {body}");
assert!(!body.contains("<!doctype html>"), "not a full page: {body}");
}
#[tokio::test]
async fn test_edit_sheet_renders_markdown_body_without_native_required() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let req = Request::builder()
.uri("/admin/note/1/edit-sheet")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, _headers, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let tag = opening_tag_for(&body, "textarea", r#"name="body""#);
assert!(
tag.contains(r#"data-widget="markdown""#),
"body textarea should render as markdown widget: {tag}"
);
assert!(
tag.contains(r#"aria-required="true""#),
"required markdown body should keep aria-required hint: {tag}"
);
assert!(
tag.contains(r#"data-required="true""#),
"required markdown body should keep non-native data-required hint: {tag}"
);
assert!(
!rendered_tag_has_attr(tag, "required"),
"enhanced markdown textarea must not render native required because the editor hides it: {tag}"
);
}
#[tokio::test]
async fn test_edit_sheet_renders_ambient_csrf_input() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let req = Request::builder()
.uri("/admin/note/1/edit-sheet")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, _headers, body) = umbral::templates::with_current_csrf(
Some("sheet-csrf-token".to_string()),
send(router, req),
)
.await;
assert_eq!(status, StatusCode::OK);
let tag = opening_tag_for(&body, "input", r#"name="csrf_token""#);
assert!(
tag.contains(r#"type="hidden""#),
"csrf input should render as a hidden input: {tag}"
);
assert!(
tag.contains(r#"value="sheet-csrf-token""#),
"csrf input should use the ambient token from SecurityPlugin scope: {tag}"
);
}
#[tokio::test]
async fn test_preview_sheet_without_htmx_redirects() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let req = Request::builder()
.uri("/admin/note/1/sheet")
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap();
let (status, headers, _body) = send(router, req).await;
assert_eq!(status, StatusCode::SEE_OTHER);
let location = headers
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
location.contains("/admin/note/") && location.contains("row="),
"redirect to changelist with row param: location={location}"
);
}
#[tokio::test]
async fn test_update_row_via_post() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let body = "title=Updated+Title&body=Updated+body&published=true";
let req = Request::builder()
.method("POST")
.uri("/admin/note/1/edit")
.header(header::COOKIE, format!("umbral_session={session}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap();
let (status, headers, _body) = send(router.clone(), req).await;
assert!(
status == StatusCode::SEE_OTHER || status == StatusCode::FOUND,
"redirect after update: status={status}"
);
let req2 = Request::builder()
.uri("/admin/note/rows?search=Updated&page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status2, _h, body2) = send(router, req2).await;
assert_eq!(status2, StatusCode::OK);
assert!(
body2.contains("Updated Title"),
"Updated Title present after update: {body2}"
);
let _ = headers;
}
#[tokio::test]
async fn test_delete_row_via_post() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let create_body = "title=ToDeleteNote&body=CanDelete&published=false";
let req_create = Request::builder()
.method("POST")
.uri("/admin/note/new")
.header(header::COOKIE, format!("umbral_session={session}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(create_body))
.unwrap();
let (create_status, _h, _) = send(router.clone(), req_create).await;
assert!(
create_status == StatusCode::SEE_OTHER || create_status == StatusCode::FOUND,
"create succeeded: status={create_status}"
);
let req_list = Request::builder()
.uri("/admin/note/rows?search=ToDeleteNote&page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (_, _h2, list_body) = send(router.clone(), req_list).await;
assert!(
list_body.contains("ToDeleteNote"),
"note found before delete: {list_body}"
);
let id_marker = "data-row-id=\"";
let note_id = list_body.find(id_marker).and_then(|pos| {
let after = &list_body[pos + id_marker.len()..];
let end = after.find('"')?;
let id = &after[..end];
if id.is_empty() {
None
} else {
Some(id.to_string())
}
});
let note_id = note_id.expect("should find note id in list fragment");
assert!(
!note_id.is_empty(),
"id should be non-empty: got '{note_id}'"
);
let req_del = Request::builder()
.method("POST")
.uri(format!("/admin/note/{note_id}/delete"))
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap();
let (del_status, _h3, _) = send(router.clone(), req_del).await;
assert!(
del_status == StatusCode::SEE_OTHER || del_status == StatusCode::FOUND,
"delete redirected: status={del_status}"
);
let req_check = Request::builder()
.uri("/admin/note/rows?search=ToDeleteNote&page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (_, _h4, check_body) = send(router, req_check).await;
let has_row_with_title = check_body
.contains("<span class=\"text-on-surface text-body-md tabular-nums\">ToDeleteNote<");
assert!(
!has_row_with_title,
"ToDeleteNote row cell gone after delete. check_body snippet={}",
&check_body[..check_body.len().min(500)]
);
}
#[tokio::test]
async fn test_new_sheet_returns_create_form() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let req = Request::builder()
.uri("/admin/note/new-sheet")
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap();
let (status, _headers, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("create") || body.contains("New"),
"create form markers present: snippet={}",
&body[..body.len().min(500)]
);
assert!(body.contains(r#"<input"#), "form inputs present: {body}");
}
#[tokio::test]
async fn test_confirm_delete_dialog_fragment() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let req = Request::builder()
.uri("/admin/note/1/_confirm-delete")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, _headers, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Delete"), "Delete button present: {body}");
assert!(body.contains("Cancel"), "Cancel button present: {body}");
assert!(!body.contains("<!doctype html>"), "not full page: {body}");
let resolved = body.contains("data-delete-url=\"/admin/note/1\"")
|| body.contains("data-delete-url=\"/admin/note/1\"");
assert!(
resolved,
"the confirm button must carry the resolved delete URL in data-delete-url; got: {body}"
);
assert!(
body.contains("umbral._deleteRow(this.dataset.deleteUrl)"),
"the confirm button must call _deleteRow with the data attribute (not an interpolated JS \
string); got: {body}"
);
assert!(
!body.contains("{{") && !body.contains("}}"),
"no un-rendered template expressions may leak into the fragment; got: {body}"
);
}
#[tokio::test]
async fn test_sheet_create_success_emits_show_toast_trigger() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let body = "title=ToastCreateNote&body=Hello&published=false";
let req = Request::builder()
.method("POST")
.uri("/admin/note/create")
.header(header::COOKIE, format!("umbral_session={session}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("hx-request", "true")
.body(Body::from(body))
.unwrap();
let (status, headers, _body) = send(router, req).await;
assert_eq!(status, StatusCode::OK, "HTMX create returns 200");
let trigger = headers
.get("hx-trigger")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
trigger.contains("showToast"),
"create success must fire showToast: {trigger}"
);
assert!(
trigger.contains("closeSheet") && trigger.contains("refreshTable"),
"create success must also close sheet + refresh table: {trigger}"
);
assert!(
trigger.contains("\"level\":\"success\""),
"toast level = success: {trigger}"
);
}
#[tokio::test]
async fn test_sheet_update_success_emits_show_toast_trigger() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let body = "title=ToastUpdateNote&body=ChangedBody&published=true";
let req = Request::builder()
.method("POST")
.uri("/admin/note/1/edit")
.header(header::COOKIE, format!("umbral_session={session}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("hx-request", "true")
.body(Body::from(body))
.unwrap();
let (status, headers, _body) = send(router, req).await;
assert_eq!(status, StatusCode::OK, "HTMX update returns 200");
let trigger = headers
.get("hx-trigger")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
trigger.contains("showToast"),
"update success must fire showToast: {trigger}"
);
assert!(
trigger.contains("\"level\":\"success\""),
"toast level = success: {trigger}"
);
}
#[tokio::test]
async fn test_sheet_delete_success_emits_show_toast_trigger() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let req_create = Request::builder()
.method("POST")
.uri("/admin/note/create")
.header(header::COOKIE, format!("umbral_session={session}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("hx-request", "true")
.body(Body::from(
"title=ToastDeleteNote&body=GoneSoon&published=false",
))
.unwrap();
send(router.clone(), req_create).await;
let req_list = Request::builder()
.uri("/admin/note/rows?search=ToastDeleteNote&page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (_, _h, list_body) = send(router.clone(), req_list).await;
let id_marker = "data-row-id=\"";
let note_id = list_body
.find(id_marker)
.and_then(|pos| {
let after = &list_body[pos + id_marker.len()..];
let end = after.find('"')?;
Some(after[..end].to_string())
})
.filter(|s| !s.is_empty())
.expect("created note id present in list");
let req_delete = Request::builder()
.method("DELETE")
.uri(format!("/admin/note/{note_id}"))
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, headers, _body) = send(router, req_delete).await;
assert_eq!(status, StatusCode::OK, "HTMX delete returns 200");
let trigger = headers
.get("hx-trigger")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
trigger.contains("showToast"),
"delete success must fire showToast: {trigger}"
);
assert!(
trigger.contains("\"level\":\"success\""),
"toast level = success: {trigger}"
);
}
#[tokio::test]
async fn test_sheet_create_renders_per_field_error_for_empty_required() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let body = "title=&body=some+body&published=false";
let req = Request::builder()
.method("POST")
.uri("/admin/note/create")
.header(header::COOKIE, format!("umbral_session={session}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("hx-request", "true")
.body(Body::from(body))
.unwrap();
let (status, _h, html) = send(router, req).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"empty required field is a 400: {html}"
);
assert!(
html.contains("This field is required."),
"the per-field required message must render: {html}"
);
assert!(
html.contains("text-error mt-0.5"),
"the inline per-field message (field_editor span, below the input) must render: {html}"
);
assert!(
!html.contains("bg-error-container/10 border border-error/20"),
"a field error renders inline, NOT in the top banner: {html}"
);
}
#[tokio::test]
async fn test_sheet_panel_is_viewport_clamped() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let req = Request::builder()
.uri("/admin/note/new-sheet")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, _h, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("min(var(--sheet-width, 640px), 100vw - 1rem)"),
"sheet width must be viewport-clamped: {body}"
);
assert!(
body.contains("left-2 right-2") || body.contains("inset-x-2"),
"sheet must become a full-width drawer on small screens"
);
}
#[tokio::test]
async fn test_sheet_edit_renders_per_field_error_inline_htmx() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let body = "title=&body=updated&published=true"; let req = Request::builder()
.method("POST")
.uri("/admin/note/1/edit")
.header(header::COOKIE, format!("umbral_session={session}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("hx-request", "true")
.body(Body::from(body))
.unwrap();
let (status, _h, html) = send(router, req).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"empty required on edit is a 400: {html}"
);
assert!(
html.contains("This field is required."),
"per-field message renders: {html}"
);
assert!(
html.contains("text-error mt-0.5"),
"inline per-field span renders: {html}"
);
assert!(
!html.contains("bg-error-container/10 border border-error/20"),
"field errors render inline, not in the top banner: {html}"
);
assert!(
html.contains("sheet-form"),
"re-renders the SHEET fragment (stays in drawer): {html}"
);
}
#[tokio::test]
async fn test_save_and_continue_refreshes_table_but_keeps_sheet_open() {
let _g = NOTE_LOCK.lock().await;
let router = boot().await.clone();
let session = fresh_staff(router.clone()).await;
let body = "title=SaveContinueNote&body=Stay&published=true&_save_continue=1";
let req = Request::builder()
.method("POST")
.uri("/admin/note/1/edit")
.header(header::COOKIE, format!("umbral_session={session}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("hx-request", "true")
.body(Body::from(body))
.unwrap();
let (status, headers, resp_body) = send(router, req).await;
assert_eq!(status, StatusCode::OK, "save-and-continue returns 200");
let trigger = headers
.get("hx-trigger")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
trigger.contains("refreshTable"),
"save-and-continue must refresh the underlying table: {trigger}"
);
assert!(
!trigger.contains("closeSheet"),
"save-and-continue must NOT close the sheet: {trigger}"
);
assert!(
!resp_body.trim().is_empty(),
"save-and-continue re-renders the sheet body"
);
}