1use std::collections::HashMap;
14use std::sync::Arc;
15
16use askama::Template;
17use axum::extract::{Form, Path, Query, State};
18use axum::response::{IntoResponse, Response};
19use axum::Extension;
20use laterite_auth::AuthenticatedUser;
21use laterite_core::query::{build, text_cast};
22use laterite_core::{t, Actor, Db, Text};
23use sea_query::{Alias, Expr, Query as Sql};
24use serde::{Deserialize, Serialize};
25
26use crate::bulk::{self, BulkContext};
27use crate::form::{self, FormConfig, PreparedForm, RelationSection};
28use crate::list::{self, ListConfig, ListParams};
29use crate::session::{FlashLevel, SessionHandle};
30use crate::{not_found, render_error, AdminState, Shell};
31
32#[derive(Clone, Debug, Serialize, Deserialize)]
36#[serde(deny_unknown_fields, default)]
37#[non_exhaustive]
38pub struct RelationConfig {
39 pub name: String,
43 pub title: Text,
46 pub label: Text,
49 pub entity: String,
51 pub foreign_key: Option<String>,
53 pub pivot: Option<PivotConfig>,
55 pub list: ListConfig,
57 pub form: FormConfig,
62 pub permission: Option<String>,
65}
66
67#[derive(Clone, Debug, Serialize, Deserialize)]
70#[serde(deny_unknown_fields, default)]
71#[non_exhaustive]
72pub struct PivotConfig {
73 pub table: String,
75 pub parent_key: String,
77 pub related_key: String,
79 pub source: String,
82 pub id_field: String,
84}
85
86impl PivotConfig {
87 pub fn new(
89 table: impl Into<String>,
90 parent_key: impl Into<String>,
91 related_key: impl Into<String>,
92 source: impl Into<String>,
93 ) -> Self {
94 Self {
95 table: table.into(),
96 parent_key: parent_key.into(),
97 related_key: related_key.into(),
98 source: source.into(),
99 id_field: "id".to_string(),
100 }
101 }
102
103 pub fn id_field(mut self, id_field: impl Into<String>) -> Self {
105 self.id_field = id_field.into();
106 self
107 }
108}
109
110impl Default for PivotConfig {
111 fn default() -> Self {
112 Self::new("", "", "", "")
113 }
114}
115
116impl RelationConfig {
117 pub fn has_many(
120 name: impl Into<String>,
121 label: impl Into<Text>,
122 entity: impl Into<String>,
123 foreign_key: impl Into<String>,
124 list: ListConfig,
125 form: FormConfig,
126 ) -> Self {
127 let name = name.into();
128 Self {
129 title: crate::descriptor::humanize(&name),
130 name,
131 label: label.into(),
132 entity: entity.into(),
133 foreign_key: Some(foreign_key.into()),
134 pivot: None,
135 list,
136 form,
137 permission: None,
138 }
139 }
140
141 pub fn belongs_to_many(
146 name: impl Into<String>,
147 label: impl Into<Text>,
148 entity: impl Into<String>,
149 pivot: PivotConfig,
150 list: ListConfig,
151 ) -> Self {
152 let name = name.into();
153 Self {
154 title: crate::descriptor::humanize(&name),
155 name,
156 label: label.into(),
157 entity: entity.into(),
158 foreign_key: None,
159 pivot: Some(pivot),
160 list,
161 form: FormConfig::default(),
162 permission: None,
163 }
164 }
165
166 pub fn form(mut self, form: FormConfig) -> Self {
168 self.form = form;
169 self
170 }
171
172 pub fn title(mut self, title: impl Into<Text>) -> Self {
174 self.title = title.into();
175 self
176 }
177
178 pub fn permission(mut self, code: impl Into<String>) -> Self {
180 self.permission = Some(code.into());
181 self
182 }
183}
184
185impl Default for RelationConfig {
186 fn default() -> Self {
187 Self {
188 name: String::new(),
189 title: Text::from(""),
190 label: Text::from(""),
191 entity: String::new(),
192 foreign_key: None,
193 pivot: None,
194 list: ListConfig::default(),
195 form: FormConfig::default(),
196 permission: None,
197 }
198 }
199}
200
201impl crate::keyed::Keyed for RelationConfig {
202 fn key(&self) -> &str {
203 &self.name
204 }
205 fn set_key(&mut self, key: String) {
206 if self.title.source().is_empty() || self.title.source() == self.name {
207 self.title = crate::descriptor::humanize(&key);
208 }
209 self.name = key;
210 }
211}
212
213pub(crate) const RESERVED: [&str; 6] = ["new", "edit", "delete", "import", "export", "columns"];
215
216enum Kind {
218 HasMany {
219 foreign_key: String,
220 },
221 ManyToMany {
222 pivot: PivotConfig,
223 attach: Arc<PreparedForm>,
225 },
226}
227
228pub(crate) struct PreparedRelation {
231 pub config: RelationConfig,
232 pub parent_base: String,
234 pub parent_entity: String,
235 pub parent_id_field: String,
236 kind: Kind,
237 form: Arc<PreparedForm>,
239 bulk: Arc<BulkContext>,
240}
241
242fn hide(form: &mut FormConfig, name: &str) {
245 match form.fields.iter_mut().find(|f| f.name == name) {
246 Some(field) => field.hidden = true,
247 None => {
248 let mut field = form::FormField::text(name, name.to_string());
249 field.hidden = true;
250 form.fields.push(field);
251 }
252 }
253}
254
255impl PreparedRelation {
256 pub(crate) fn prepare(
257 parent: &crate::Resource,
258 mut config: RelationConfig,
259 field_types: &crate::field::FieldRegistry,
260 column_types: &list::ColumnRegistry,
261 permission_codes: &std::collections::HashSet<String>,
262 persisters: &crate::persist::PersisterRegistry,
263 listeners: &[laterite_core::ModelListenerReg],
264 ) -> Result<Self, String> {
265 let name = config.name.clone();
266 let fail = |message: String| format!("relation `{name}`: {message}");
267 if !crate::sql::valid_ident(&config.name) || RESERVED.contains(&config.name.as_str()) {
268 return Err(fail("the name is one the parent's own routes use".into()));
269 }
270 config.list.entity = config.entity.clone();
271 list::check(&config.list, column_types, permission_codes).map_err(fail)?;
272 if config.form.title.source().is_empty() {
273 config.form.title = config.label.clone();
274 }
275 let prepare = |form: FormConfig| {
276 PreparedForm::prepare(form, field_types, persisters, listeners).map_err(fail)
277 };
278 let (kind, form) = match (config.foreign_key.clone(), config.pivot.clone()) {
279 (Some(foreign_key), None) => {
280 if !crate::sql::valid_ident(&foreign_key) {
281 return Err(fail("the foreign key is not a valid identifier".into()));
282 }
283 config.form.entity = config.entity.clone();
284 config.form.id_field = config.list.id_field.clone();
285 hide(&mut config.form, &foreign_key);
287 (Kind::HasMany { foreign_key }, prepare(config.form.clone())?)
288 }
289 (None, Some(pivot)) => {
290 if ![
291 &pivot.table,
292 &pivot.parent_key,
293 &pivot.related_key,
294 &pivot.id_field,
295 ]
296 .iter()
297 .all(|ident| crate::sql::valid_ident(ident))
298 {
299 return Err(fail("the pivot names an invalid identifier".into()));
300 }
301 if pivot.source.is_empty() {
302 return Err(fail("the pivot names no picker source".into()));
303 }
304 config.form.entity = pivot.table.clone();
305 config.form.id_field = pivot.id_field.clone();
306 hide(&mut config.form, &pivot.parent_key);
307 let mut attach = config.form.clone();
310 if !attach.fields.iter().any(|f| f.name == pivot.related_key) {
311 attach.fields.insert(
312 0,
313 form::FormField::reference(
314 &pivot.related_key,
315 config.label.clone(),
316 &pivot.source,
317 )
318 .required(),
319 );
320 }
321 hide(&mut config.form, &pivot.related_key);
322 let attach = Arc::new(prepare(attach)?);
323 (
324 Kind::ManyToMany { pivot, attach },
325 prepare(config.form.clone())?,
326 )
327 }
328 _ => return Err(fail("declare exactly one of foreign_key and pivot".into())),
329 };
330 let persister = form.persister();
331 let entity = form.config.entity.clone();
332 let bulk = Arc::new(BulkContext {
333 base_path: String::new(),
334 listeners: listeners
335 .iter()
336 .filter(|reg| reg.matches(&entity))
337 .map(|reg| reg.listener.clone())
338 .collect(),
339 entity,
340 persister,
341 });
342 Ok(Self {
343 parent_base: parent.base_path.clone(),
344 parent_entity: parent.list.entity.clone(),
345 parent_id_field: parent.list.id_field.clone(),
346 config,
347 kind,
348 form: Arc::new(form),
349 bulk,
350 })
351 }
352
353 pub(crate) fn path(&self, parent_id: &str) -> String {
355 format!("{}/{}/{}", self.parent_base, parent_id, self.config.name)
356 }
357
358 fn parent_edit(&self, parent_id: &str) -> String {
360 format!("{}/{}/edit", self.parent_base, parent_id)
361 }
362
363 fn rebase(&self, form: &PreparedForm, parent_id: &str) -> Arc<PreparedForm> {
365 let mut form = form.clone();
366 form.config.base_path = self.path(parent_id);
367 form.config.done_path = Some(self.parent_edit(parent_id));
368 Arc::new(form)
369 }
370
371 fn new_form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
373 match &self.kind {
374 Kind::HasMany { .. } => self.rebase(&self.form, parent_id),
375 Kind::ManyToMany { attach, .. } => self.rebase(attach, parent_id),
376 }
377 }
378
379 fn form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
381 self.rebase(&self.form, parent_id)
382 }
383
384 fn list_for(&self, parent_id: &str) -> ListConfig {
386 let mut list = self.config.list.clone();
387 list.remember = false;
388 match &self.kind {
389 Kind::HasMany { foreign_key } => {
390 list.edit_base = Some(self.path(parent_id));
391 list.creatable = true;
392 list.tree = None;
394 list.scope = Some(list::Scope::Column {
395 column: foreign_key.clone(),
396 value: Some(parent_id.to_string()),
397 });
398 }
399 Kind::ManyToMany { pivot, .. } => {
400 list.reorder = None;
403 list.edit_base =
405 (!self.config.form.fields.is_empty()).then(|| self.path(parent_id));
406 list.creatable = false;
407 list.scope = Some(list::Scope::Pivot {
408 table: pivot.table.clone(),
409 parent_key: pivot.parent_key.clone(),
410 related_key: pivot.related_key.clone(),
411 id_field: pivot.id_field.clone(),
412 value: parent_id.to_string(),
413 });
414 }
415 }
416 list
417 }
418
419 pub(crate) fn reorderable(&self) -> bool {
422 matches!(self.kind, Kind::HasMany { .. }) && self.config.list.reorder.is_some()
423 }
424
425 fn row_action(&self, path: &str, shell: &Shell) -> list::RowAction {
427 let (label, confirm) = match &self.kind {
428 Kind::HasMany { .. } => (t!("Delete"), t!("Delete this record?")),
429 Kind::ManyToMany { .. } => (t!("Remove"), t!("Remove this record?")),
430 };
431 list::RowAction {
432 path: format!("{path}/delete"),
433 label: shell.tt(&label),
434 confirm: shell.tt(&confirm),
435 }
436 }
437
438 fn new_label(&self, shell: &Shell) -> String {
440 let label = shell.tt(&self.config.label);
441 match &self.kind {
442 Kind::HasMany { .. } => shell.tt(&t!("New {label}", label = label)),
443 Kind::ManyToMany { .. } => shell.tt(&t!("Attach {label}", label = label)),
444 }
445 }
446
447 async fn exists(
449 &self,
450 db: &Db,
451 table: &str,
452 pairs: &[(&str, &str)],
453 ) -> Result<bool, sqlx::Error> {
454 let (sql, values) = {
455 let cast = text_cast(db.backend);
456 let mut select = Sql::select();
457 select.expr(Expr::val(1).count()).from(Alias::new(table));
458 for (column, value) in pairs {
459 select.and_where(
460 Expr::col(Alias::new(*column))
461 .cast_as(Alias::new(cast))
462 .eq(value.to_string()),
463 );
464 }
465 build(db.backend, select)
466 };
467 let (n,): (i64,) = laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
468 .fetch_one(&db.pool)
469 .await?;
470 Ok(n > 0)
471 }
472
473 async fn parent_exists(&self, db: &Db, parent_id: &str) -> Result<bool, sqlx::Error> {
476 self.exists(
477 db,
478 &self.parent_entity,
479 &[(&self.parent_id_field, parent_id)],
480 )
481 .await
482 }
483
484 async fn owned(&self, db: &Db, parent_id: &str, child_id: &str) -> Result<bool, sqlx::Error> {
488 match &self.kind {
489 Kind::HasMany { foreign_key } => {
490 self.exists(
491 db,
492 &self.config.entity,
493 &[
494 (&self.config.list.id_field, child_id),
495 (foreign_key, parent_id),
496 ],
497 )
498 .await
499 }
500 Kind::ManyToMany { pivot, .. } => {
501 self.exists(
502 db,
503 &pivot.table,
504 &[(&pivot.id_field, child_id), (&pivot.parent_key, parent_id)],
505 )
506 .await
507 }
508 }
509 }
510
511 async fn pivot_related(
513 &self,
514 db: &Db,
515 pivot: &PivotConfig,
516 parent_id: &str,
517 child_id: &str,
518 ) -> Result<Option<String>, sqlx::Error> {
519 let (sql, values) = {
520 let cast = text_cast(db.backend);
521 build(
522 db.backend,
523 Sql::select()
524 .expr_as(
525 Expr::col(Alias::new(&pivot.related_key)).cast_as(Alias::new(cast)),
526 Alias::new("related"),
527 )
528 .from(Alias::new(&pivot.table))
529 .and_where(
530 Expr::col(Alias::new(&pivot.id_field))
531 .cast_as(Alias::new(cast))
532 .eq(child_id.to_string()),
533 )
534 .and_where(
535 Expr::col(Alias::new(&pivot.parent_key))
536 .cast_as(Alias::new(cast))
537 .eq(parent_id.to_string()),
538 )
539 .to_owned(),
540 )
541 };
542 let row: Option<(String,)> =
543 laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
544 .fetch_optional(&db.pool)
545 .await?;
546 Ok(row.map(|(related,)| related))
547 }
548
549 pub(crate) async fn section(
551 &self,
552 state: &AdminState,
553 parent_id: &str,
554 user: &AuthenticatedUser,
555 shell: &Shell,
556 ) -> Option<RelationSection> {
557 if let Some(code) = &self.config.permission {
558 if !user.permissions.allows(code) {
559 return None;
560 }
561 }
562 let path = self.path(parent_id);
563 let html = list::fragment(
564 state,
565 &self.list_for(parent_id),
566 &path,
567 ListParams::default(),
568 &HashMap::new(),
569 user,
570 shell.clone(),
571 &format!("lat-rel-{}", self.config.name),
572 Some(self.row_action(&path, shell)),
573 )
574 .await
575 .ok()?;
576 Some(RelationSection {
577 title: shell.tt(&self.config.title),
578 new_href: Some(format!("{path}/new")),
579 new_label: self.new_label(shell),
580 html,
581 })
582 }
583}
584
585#[allow(clippy::too_many_arguments)]
587pub(crate) async fn index(
588 State(state): State<AdminState>,
589 Extension(shell): Extension<Shell>,
590 Extension(user): Extension<AuthenticatedUser>,
591 Path(parent_id): Path<String>,
592 Query(params): Query<ListParams>,
593 Query(raw): Query<HashMap<String, String>>,
594 headers: axum::http::HeaderMap,
595 rel: Arc<PreparedRelation>,
596) -> Response {
597 match rel.parent_exists(&state.db, &parent_id).await {
598 Ok(true) => {}
599 Ok(false) => return not_found(),
600 Err(_) => return render_error(),
601 }
602 let path = rel.path(&parent_id);
603 let html = match list::fragment(
604 &state,
605 &rel.list_for(&parent_id),
606 &path,
607 params,
608 &raw,
609 &user,
610 shell.clone(),
611 &format!("lat-rel-{}", rel.config.name),
612 Some(rel.row_action(&path, &shell)),
613 )
614 .await
615 {
616 Ok(html) => html,
617 Err(()) => return render_error(),
618 };
619 if form::is_htmx(&headers) {
620 return axum::response::Html(html.into_string()).into_response();
621 }
622 let page = SectionPage {
625 shell: &shell,
626 back: rel.parent_edit(&parent_id),
627 new_href: format!("{path}/new"),
628 new_label: rel.new_label(&shell),
629 html,
630 };
631 match crate::html::Markup::from_template(&page) {
632 Ok(body) => shell.page(&rel.config.title, body),
633 Err(_) => render_error(),
634 }
635}
636
637#[derive(Template)]
639#[template(path = "relation_section.html")]
640struct SectionPage<'a> {
641 shell: &'a Shell,
642 back: String,
643 new_href: String,
644 new_label: String,
645 html: crate::html::Markup,
646}
647
648pub(crate) async fn new_form(
649 State(state): State<AdminState>,
650 Extension(shell): Extension<Shell>,
651 Path(parent_id): Path<String>,
652 rel: Arc<PreparedRelation>,
653) -> Response {
654 match rel.parent_exists(&state.db, &parent_id).await {
655 Ok(true) => {}
656 Ok(false) => return not_found(),
657 Err(_) => return render_error(),
658 }
659 form::new_form(&state, &rel.new_form_for(&parent_id), shell, Vec::new())
660}
661
662#[allow(clippy::too_many_arguments)]
663pub(crate) async fn create(
664 State(state): State<AdminState>,
665 Extension(shell): Extension<Shell>,
666 Extension(user): Extension<AuthenticatedUser>,
667 Extension(session): Extension<SessionHandle>,
668 Path(parent_id): Path<String>,
669 headers: axum::http::HeaderMap,
670 Form(mut data): Form<HashMap<String, String>>,
671 rel: Arc<PreparedRelation>,
672) -> Response {
673 match rel.parent_exists(&state.db, &parent_id).await {
674 Ok(true) => {}
675 Ok(false) => return not_found(),
676 Err(_) => return render_error(),
677 }
678 match &rel.kind {
680 Kind::HasMany { foreign_key } => {
681 data.insert(foreign_key.clone(), parent_id.clone());
682 }
683 Kind::ManyToMany { pivot, .. } => {
684 data.insert(pivot.parent_key.clone(), parent_id.clone());
685 let related = data
687 .get(&pivot.related_key)
688 .map(|v| v.trim().to_string())
689 .filter(|v| !v.is_empty());
690 if let Some(related) = related {
691 let present = rel
692 .exists(
693 &state.db,
694 &rel.config.entity,
695 &[(&rel.config.list.id_field, &related)],
696 )
697 .await;
698 let attached = rel
699 .exists(
700 &state.db,
701 &pivot.table,
702 &[
703 (&pivot.parent_key, &parent_id),
704 (&pivot.related_key, &related),
705 ],
706 )
707 .await;
708 let refusal = match (present, attached) {
709 (Ok(false), _) => Some(t!("There is no such record to attach.")),
710 (_, Ok(true)) => Some(t!("That record is already attached.")),
711 (Err(_), _) | (_, Err(_)) => return render_error(),
712 _ => None,
713 };
714 if let Some(message) = refusal {
715 session.push_flash(FlashLevel::Error, message);
716 let back = format!("{}/new", rel.path(&parent_id));
717 return form::saved_response(form::is_htmx(&headers), &back);
718 }
719 }
720 }
721 }
722 form::create(
723 &state,
724 &rel.new_form_for(&parent_id),
725 data,
726 shell,
727 &user,
728 &session,
729 &headers,
730 )
731 .await
732}
733
734pub(crate) async fn edit_form(
735 State(state): State<AdminState>,
736 Extension(shell): Extension<Shell>,
737 Path((parent_id, child_id)): Path<(String, String)>,
738 rel: Arc<PreparedRelation>,
739) -> Response {
740 match rel.owned(&state.db, &parent_id, &child_id).await {
741 Ok(true) => {}
742 Ok(false) => return not_found(),
743 Err(_) => return render_error(),
744 }
745 form::edit_form(
746 &state,
747 &rel.form_for(&parent_id),
748 child_id,
749 shell,
750 Vec::new(),
751 )
752 .await
753}
754
755#[allow(clippy::too_many_arguments)]
756pub(crate) async fn update(
757 State(state): State<AdminState>,
758 Extension(shell): Extension<Shell>,
759 Extension(user): Extension<AuthenticatedUser>,
760 Extension(session): Extension<SessionHandle>,
761 Path((parent_id, child_id)): Path<(String, String)>,
762 headers: axum::http::HeaderMap,
763 Form(mut data): Form<HashMap<String, String>>,
764 rel: Arc<PreparedRelation>,
765) -> Response {
766 match &rel.kind {
769 Kind::HasMany { foreign_key } => {
770 match rel.owned(&state.db, &parent_id, &child_id).await {
771 Ok(true) => {}
772 Ok(false) => return not_found(),
773 Err(_) => return render_error(),
774 }
775 data.insert(foreign_key.clone(), parent_id.clone());
776 }
777 Kind::ManyToMany { pivot, .. } => {
778 match rel
779 .pivot_related(&state.db, pivot, &parent_id, &child_id)
780 .await
781 {
782 Ok(Some(related)) => {
783 data.insert(pivot.related_key.clone(), related);
784 }
785 Ok(None) => return not_found(),
786 Err(_) => return render_error(),
787 }
788 data.insert(pivot.parent_key.clone(), parent_id.clone());
789 }
790 }
791 form::update(
792 &state,
793 &rel.form_for(&parent_id),
794 child_id,
795 data,
796 shell,
797 &user,
798 &session,
799 &headers,
800 )
801 .await
802}
803
804#[allow(clippy::too_many_arguments)]
806pub(crate) async fn reorder(
807 State(state): State<AdminState>,
808 Extension(shell): Extension<Shell>,
809 Extension(user): Extension<AuthenticatedUser>,
810 Extension(session): Extension<SessionHandle>,
811 Path(parent_id): Path<String>,
812 headers: axum::http::HeaderMap,
813 Form(pairs): Form<Vec<(String, String)>>,
814 rel: Arc<PreparedRelation>,
815) -> Response {
816 match rel.parent_exists(&state.db, &parent_id).await {
817 Ok(true) => {}
818 Ok(false) => return not_found(),
819 Err(_) => return render_error(),
820 }
821 let path = rel.path(&parent_id);
822 let row_action = Some(rel.row_action(&path, &shell));
823 list::reorder_handler(
824 &state,
825 &rel.list_for(&parent_id),
826 &path,
827 &format!("lat-rel-{}", rel.config.name),
828 row_action,
829 &user,
830 &session,
831 shell,
832 &headers,
833 &pairs,
834 )
835 .await
836}
837
838#[allow(clippy::too_many_arguments)]
841pub(crate) async fn delete(
842 State(state): State<AdminState>,
843 Extension(shell): Extension<Shell>,
844 Extension(user): Extension<AuthenticatedUser>,
845 Extension(session): Extension<SessionHandle>,
846 Path(parent_id): Path<String>,
847 headers: axum::http::HeaderMap,
848 Form(pairs): Form<Vec<(String, String)>>,
849 rel: Arc<PreparedRelation>,
850) -> Response {
851 let ids: Vec<String> = pairs
852 .iter()
853 .filter(|(k, _)| k == "id")
854 .map(|(_, v)| v.trim().to_string())
855 .filter(|v| !v.is_empty())
856 .collect();
857 let mut owned = Vec::new();
858 for id in &ids {
859 match rel.owned(&state.db, &parent_id, id).await {
860 Ok(true) => owned.push(id.clone()),
861 Ok(false) => {}
862 Err(_) => return render_error(),
863 }
864 }
865 let actor = Actor::from(&user);
866 let outcome = bulk::delete_selected(&state, &rel.bulk, &owned, &actor).await;
867 if outcome.deleted > 0 {
868 let n = outcome.deleted as i64;
869 let message = match &rel.kind {
870 Kind::HasMany { .. } => {
871 laterite_core::tn!("Deleted {n} record.", "Deleted {n} records.", n = n)
872 }
873 Kind::ManyToMany { .. } => {
874 laterite_core::tn!("Removed {n} record.", "Removed {n} records.", n = n)
875 }
876 };
877 session.push_flash(FlashLevel::Success, message);
878 }
879 for message in outcome.refused {
880 session.push_flash(FlashLevel::Error, message);
881 }
882 if outcome.failed > 0 {
883 session.push_flash(FlashLevel::Error, t!("Some records could not be deleted."));
884 }
885 let _ = shell;
887 form::saved_response(form::is_htmx(&headers), &rel.parent_edit(&parent_id))
888}