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) {
247 match form.fields.iter_mut().find(|f| f.name == name) {
248 Some(field) => {
249 field.hidden = true;
250 if field.field_type == "text" {
251 field.field_type = "id".to_string();
252 }
253 }
254 None => {
255 let mut field = form::FormField::of(name, name.to_string(), "id");
256 field.hidden = true;
257 form.fields.push(field);
258 }
259 }
260}
261
262impl PreparedRelation {
263 pub(crate) fn prepare(
264 parent: &crate::Resource,
265 mut config: RelationConfig,
266 field_types: &crate::field::FieldRegistry,
267 column_types: &list::ColumnRegistry,
268 permission_codes: &std::collections::HashSet<String>,
269 persisters: &crate::persist::PersisterRegistry,
270 listeners: &[laterite_core::ModelListenerReg],
271 ) -> Result<Self, String> {
272 let name = config.name.clone();
273 let fail = |message: String| format!("relation `{name}`: {message}");
274 if !crate::sql::valid_ident(&config.name) || RESERVED.contains(&config.name.as_str()) {
275 return Err(fail("the name is one the parent's own routes use".into()));
276 }
277 config.list.entity = config.entity.clone();
278 list::check(&config.list, column_types, permission_codes).map_err(fail)?;
279 if config.form.title.source().is_empty() {
280 config.form.title = config.label.clone();
281 }
282 let prepare = |form: FormConfig| {
283 PreparedForm::prepare(form, field_types, persisters, listeners).map_err(fail)
284 };
285 let (kind, form) = match (config.foreign_key.clone(), config.pivot.clone()) {
286 (Some(foreign_key), None) => {
287 if !crate::sql::valid_ident(&foreign_key) {
288 return Err(fail("the foreign key is not a valid identifier".into()));
289 }
290 config.form.entity = config.entity.clone();
291 config.form.id_field = config.list.id_field.clone();
292 hide(&mut config.form, &foreign_key);
294 (Kind::HasMany { foreign_key }, prepare(config.form.clone())?)
295 }
296 (None, Some(pivot)) => {
297 if ![
298 &pivot.table,
299 &pivot.parent_key,
300 &pivot.related_key,
301 &pivot.id_field,
302 ]
303 .iter()
304 .all(|ident| crate::sql::valid_ident(ident))
305 {
306 return Err(fail("the pivot names an invalid identifier".into()));
307 }
308 if pivot.source.is_empty() {
309 return Err(fail("the pivot names no picker source".into()));
310 }
311 config.form.entity = pivot.table.clone();
312 config.form.id_field = pivot.id_field.clone();
313 hide(&mut config.form, &pivot.parent_key);
314 let mut attach = config.form.clone();
317 if !attach.fields.iter().any(|f| f.name == pivot.related_key) {
318 attach.fields.insert(
319 0,
320 form::FormField::reference(
321 &pivot.related_key,
322 config.label.clone(),
323 &pivot.source,
324 )
325 .required(),
326 );
327 }
328 hide(&mut config.form, &pivot.related_key);
329 let attach = Arc::new(prepare(attach)?);
330 (
331 Kind::ManyToMany { pivot, attach },
332 prepare(config.form.clone())?,
333 )
334 }
335 _ => return Err(fail("declare exactly one of foreign_key and pivot".into())),
336 };
337 let persister = form.persister();
338 let entity = form.config.entity.clone();
339 let bulk = Arc::new(BulkContext {
340 base_path: String::new(),
341 listeners: listeners
342 .iter()
343 .filter(|reg| reg.matches(&entity))
344 .map(|reg| reg.listener.clone())
345 .collect(),
346 entity,
347 persister,
348 });
349 Ok(Self {
350 parent_base: parent.base_path.clone(),
351 parent_entity: parent.list.entity.clone(),
352 parent_id_field: parent.list.id_field.clone(),
353 config,
354 kind,
355 form: Arc::new(form),
356 bulk,
357 })
358 }
359
360 pub(crate) fn path(&self, parent_id: &str) -> String {
362 format!("{}/{}/{}", self.parent_base, parent_id, self.config.name)
363 }
364
365 fn parent_edit(&self, parent_id: &str) -> String {
367 format!("{}/{}/edit", self.parent_base, parent_id)
368 }
369
370 fn rebase(&self, form: &PreparedForm, parent_id: &str) -> Arc<PreparedForm> {
372 let mut form = form.clone();
373 form.config.base_path = self.path(parent_id);
374 form.config.done_path = Some(self.parent_edit(parent_id));
375 Arc::new(form)
376 }
377
378 fn new_form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
380 match &self.kind {
381 Kind::HasMany { .. } => self.rebase(&self.form, parent_id),
382 Kind::ManyToMany { attach, .. } => self.rebase(attach, parent_id),
383 }
384 }
385
386 fn form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
388 self.rebase(&self.form, parent_id)
389 }
390
391 fn list_for(&self, parent_id: &str) -> ListConfig {
393 let mut list = self.config.list.clone();
394 list.remember = false;
395 match &self.kind {
396 Kind::HasMany { foreign_key } => {
397 list.edit_base = Some(self.path(parent_id));
398 list.creatable = true;
399 list.tree = None;
401 list.scope = Some(list::Scope::Column {
402 column: foreign_key.clone(),
403 value: Some(parent_id.to_string()),
404 });
405 }
406 Kind::ManyToMany { pivot, .. } => {
407 list.reorder = None;
410 list.edit_base =
412 (!self.config.form.fields.is_empty()).then(|| self.path(parent_id));
413 list.creatable = false;
414 list.scope = Some(list::Scope::Pivot {
415 table: pivot.table.clone(),
416 parent_key: pivot.parent_key.clone(),
417 related_key: pivot.related_key.clone(),
418 id_field: pivot.id_field.clone(),
419 value: parent_id.to_string(),
420 });
421 }
422 }
423 list
424 }
425
426 pub(crate) fn reorderable(&self) -> bool {
429 matches!(self.kind, Kind::HasMany { .. }) && self.config.list.reorder.is_some()
430 }
431
432 fn row_action(&self, path: &str, shell: &Shell) -> list::RowAction {
434 let (label, confirm, icon) = match &self.kind {
435 Kind::HasMany { .. } => (t!("Delete"), t!("Delete this record?"), "trash-2"),
436 Kind::ManyToMany { .. } => (t!("Remove"), t!("Remove this record?"), "unlink"),
437 };
438 list::RowAction {
439 path: format!("{path}/delete"),
440 label: shell.tt(&label),
441 confirm: shell.tt(&confirm),
442 icon,
443 }
444 }
445
446 fn new_label(&self, shell: &Shell) -> String {
448 let label = shell.tt(&self.config.label);
449 match &self.kind {
450 Kind::HasMany { .. } => shell.tt(&t!("New {label}", label = label)),
451 Kind::ManyToMany { .. } => shell.tt(&t!("Attach {label}", label = label)),
452 }
453 }
454
455 async fn exists(
457 &self,
458 db: &Db,
459 table: &str,
460 pairs: &[(&str, &str)],
461 ) -> Result<bool, sqlx::Error> {
462 let (sql, values) = {
463 let cast = text_cast(db.backend);
464 let mut select = Sql::select();
465 select.expr(Expr::val(1).count()).from(Alias::new(table));
466 for (column, value) in pairs {
467 select.and_where(
468 Expr::col(Alias::new(*column))
469 .cast_as(Alias::new(cast))
470 .eq(value.to_string()),
471 );
472 }
473 build(db.backend, select)
474 };
475 let (n,): (i64,) = laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
476 .fetch_one(&db.pool)
477 .await?;
478 Ok(n > 0)
479 }
480
481 async fn parent_exists(&self, db: &Db, parent_id: &str) -> Result<bool, sqlx::Error> {
484 self.exists(
485 db,
486 &self.parent_entity,
487 &[(&self.parent_id_field, parent_id)],
488 )
489 .await
490 }
491
492 async fn owned(&self, db: &Db, parent_id: &str, child_id: &str) -> Result<bool, sqlx::Error> {
496 match &self.kind {
497 Kind::HasMany { foreign_key } => {
498 self.exists(
499 db,
500 &self.config.entity,
501 &[
502 (&self.config.list.id_field, child_id),
503 (foreign_key, parent_id),
504 ],
505 )
506 .await
507 }
508 Kind::ManyToMany { pivot, .. } => {
509 self.exists(
510 db,
511 &pivot.table,
512 &[(&pivot.id_field, child_id), (&pivot.parent_key, parent_id)],
513 )
514 .await
515 }
516 }
517 }
518
519 async fn pivot_related(
521 &self,
522 db: &Db,
523 pivot: &PivotConfig,
524 parent_id: &str,
525 child_id: &str,
526 ) -> Result<Option<String>, sqlx::Error> {
527 let (sql, values) = {
528 let cast = text_cast(db.backend);
529 build(
530 db.backend,
531 Sql::select()
532 .expr_as(
533 Expr::col(Alias::new(&pivot.related_key)).cast_as(Alias::new(cast)),
534 Alias::new("related"),
535 )
536 .from(Alias::new(&pivot.table))
537 .and_where(
538 Expr::col(Alias::new(&pivot.id_field))
539 .cast_as(Alias::new(cast))
540 .eq(child_id.to_string()),
541 )
542 .and_where(
543 Expr::col(Alias::new(&pivot.parent_key))
544 .cast_as(Alias::new(cast))
545 .eq(parent_id.to_string()),
546 )
547 .to_owned(),
548 )
549 };
550 let row: Option<(String,)> =
551 laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
552 .fetch_optional(&db.pool)
553 .await?;
554 Ok(row.map(|(related,)| related))
555 }
556
557 pub(crate) async fn section(
559 &self,
560 state: &AdminState,
561 parent_id: &str,
562 user: &AuthenticatedUser,
563 shell: &Shell,
564 ) -> Option<RelationSection> {
565 if let Some(code) = &self.config.permission {
566 if !user.permissions.allows(code) {
567 return None;
568 }
569 }
570 let path = self.path(parent_id);
571 let html = list::fragment(
572 state,
573 &self.list_for(parent_id),
574 &path,
575 ListParams::default(),
576 &HashMap::new(),
577 user,
578 shell.clone(),
579 &format!("lat-rel-{}", self.config.name),
580 Some(self.row_action(&path, shell)),
581 )
582 .await
583 .ok()?;
584 Some(RelationSection {
585 title: shell.tt(&self.config.title),
586 new_href: Some(format!("{path}/new")),
587 new_label: self.new_label(shell),
588 html,
589 })
590 }
591}
592
593#[allow(clippy::too_many_arguments)]
595pub(crate) async fn index(
596 State(state): State<AdminState>,
597 Extension(shell): Extension<Shell>,
598 Extension(user): Extension<AuthenticatedUser>,
599 Path(parent_id): Path<String>,
600 Query(params): Query<ListParams>,
601 Query(raw): Query<HashMap<String, String>>,
602 headers: axum::http::HeaderMap,
603 rel: Arc<PreparedRelation>,
604) -> Response {
605 match rel.parent_exists(&state.db, &parent_id).await {
606 Ok(true) => {}
607 Ok(false) => return not_found(),
608 Err(_) => return render_error(),
609 }
610 let path = rel.path(&parent_id);
611 let html = match list::fragment(
612 &state,
613 &rel.list_for(&parent_id),
614 &path,
615 params,
616 &raw,
617 &user,
618 shell.clone(),
619 &format!("lat-rel-{}", rel.config.name),
620 Some(rel.row_action(&path, &shell)),
621 )
622 .await
623 {
624 Ok(html) => html,
625 Err(()) => return render_error(),
626 };
627 if form::is_htmx(&headers) {
628 return axum::response::Html(html.into_string()).into_response();
629 }
630 let page = SectionPage {
633 shell: &shell,
634 back: rel.parent_edit(&parent_id),
635 new_href: format!("{path}/new"),
636 new_label: rel.new_label(&shell),
637 html,
638 };
639 match crate::html::Markup::from_template(&page) {
640 Ok(body) => shell.page(&rel.config.title, body),
641 Err(_) => render_error(),
642 }
643}
644
645#[derive(Template)]
647#[template(path = "relation_section.html")]
648struct SectionPage<'a> {
649 shell: &'a Shell,
650 back: String,
651 new_href: String,
652 new_label: String,
653 html: crate::html::Markup,
654}
655
656pub(crate) async fn new_form(
657 State(state): State<AdminState>,
658 Extension(shell): Extension<Shell>,
659 Path(parent_id): Path<String>,
660 rel: Arc<PreparedRelation>,
661) -> Response {
662 match rel.parent_exists(&state.db, &parent_id).await {
663 Ok(true) => {}
664 Ok(false) => return not_found(),
665 Err(_) => return render_error(),
666 }
667 form::new_form(&state, &rel.new_form_for(&parent_id), shell, Vec::new())
668}
669
670#[allow(clippy::too_many_arguments)]
671pub(crate) async fn create(
672 State(state): State<AdminState>,
673 Extension(shell): Extension<Shell>,
674 Extension(user): Extension<AuthenticatedUser>,
675 Extension(session): Extension<SessionHandle>,
676 Path(parent_id): Path<String>,
677 headers: axum::http::HeaderMap,
678 Form(mut data): Form<HashMap<String, String>>,
679 rel: Arc<PreparedRelation>,
680) -> Response {
681 match rel.parent_exists(&state.db, &parent_id).await {
682 Ok(true) => {}
683 Ok(false) => return not_found(),
684 Err(_) => return render_error(),
685 }
686 match &rel.kind {
688 Kind::HasMany { foreign_key } => {
689 data.insert(foreign_key.clone(), parent_id.clone());
690 }
691 Kind::ManyToMany { pivot, .. } => {
692 data.insert(pivot.parent_key.clone(), parent_id.clone());
693 let related = data
695 .get(&pivot.related_key)
696 .map(|v| v.trim().to_string())
697 .filter(|v| !v.is_empty());
698 if let Some(related) = related {
699 let present = rel
700 .exists(
701 &state.db,
702 &rel.config.entity,
703 &[(&rel.config.list.id_field, &related)],
704 )
705 .await;
706 let attached = rel
707 .exists(
708 &state.db,
709 &pivot.table,
710 &[
711 (&pivot.parent_key, &parent_id),
712 (&pivot.related_key, &related),
713 ],
714 )
715 .await;
716 let refusal = match (present, attached) {
717 (Ok(false), _) => Some(t!("There is no such record to attach.")),
718 (_, Ok(true)) => Some(t!("That record is already attached.")),
719 (Err(_), _) | (_, Err(_)) => return render_error(),
720 _ => None,
721 };
722 if let Some(message) = refusal {
723 session.push_flash(FlashLevel::Error, message);
724 let back = format!("{}/new", rel.path(&parent_id));
725 return form::saved_response(form::is_htmx(&headers), &back);
726 }
727 }
728 }
729 }
730 form::create(
731 &state,
732 &rel.new_form_for(&parent_id),
733 data,
734 shell,
735 &user,
736 &session,
737 &headers,
738 )
739 .await
740}
741
742pub(crate) async fn edit_form(
743 State(state): State<AdminState>,
744 Extension(shell): Extension<Shell>,
745 Path((parent_id, child_id)): Path<(String, String)>,
746 rel: Arc<PreparedRelation>,
747) -> Response {
748 match rel.owned(&state.db, &parent_id, &child_id).await {
749 Ok(true) => {}
750 Ok(false) => return not_found(),
751 Err(_) => return render_error(),
752 }
753 form::edit_form(
754 &state,
755 &rel.form_for(&parent_id),
756 child_id,
757 shell,
758 Vec::new(),
759 )
760 .await
761}
762
763#[allow(clippy::too_many_arguments)]
764pub(crate) async fn update(
765 State(state): State<AdminState>,
766 Extension(shell): Extension<Shell>,
767 Extension(user): Extension<AuthenticatedUser>,
768 Extension(session): Extension<SessionHandle>,
769 Path((parent_id, child_id)): Path<(String, String)>,
770 headers: axum::http::HeaderMap,
771 Form(mut data): Form<HashMap<String, String>>,
772 rel: Arc<PreparedRelation>,
773) -> Response {
774 match &rel.kind {
777 Kind::HasMany { foreign_key } => {
778 match rel.owned(&state.db, &parent_id, &child_id).await {
779 Ok(true) => {}
780 Ok(false) => return not_found(),
781 Err(_) => return render_error(),
782 }
783 data.insert(foreign_key.clone(), parent_id.clone());
784 }
785 Kind::ManyToMany { pivot, .. } => {
786 match rel
787 .pivot_related(&state.db, pivot, &parent_id, &child_id)
788 .await
789 {
790 Ok(Some(related)) => {
791 data.insert(pivot.related_key.clone(), related);
792 }
793 Ok(None) => return not_found(),
794 Err(_) => return render_error(),
795 }
796 data.insert(pivot.parent_key.clone(), parent_id.clone());
797 }
798 }
799 form::update(
800 &state,
801 &rel.form_for(&parent_id),
802 child_id,
803 data,
804 shell,
805 &user,
806 &session,
807 &headers,
808 )
809 .await
810}
811
812#[allow(clippy::too_many_arguments)]
814pub(crate) async fn reorder(
815 State(state): State<AdminState>,
816 Extension(shell): Extension<Shell>,
817 Extension(user): Extension<AuthenticatedUser>,
818 Extension(session): Extension<SessionHandle>,
819 Path(parent_id): Path<String>,
820 headers: axum::http::HeaderMap,
821 Form(pairs): Form<Vec<(String, String)>>,
822 rel: Arc<PreparedRelation>,
823) -> Response {
824 match rel.parent_exists(&state.db, &parent_id).await {
825 Ok(true) => {}
826 Ok(false) => return not_found(),
827 Err(_) => return render_error(),
828 }
829 let path = rel.path(&parent_id);
830 let row_action = Some(rel.row_action(&path, &shell));
831 list::reorder_handler(
832 &state,
833 &rel.list_for(&parent_id),
834 &path,
835 &format!("lat-rel-{}", rel.config.name),
836 row_action,
837 &user,
838 &session,
839 shell,
840 &headers,
841 &pairs,
842 )
843 .await
844}
845
846#[allow(clippy::too_many_arguments)]
849pub(crate) async fn delete(
850 State(state): State<AdminState>,
851 Extension(shell): Extension<Shell>,
852 Extension(user): Extension<AuthenticatedUser>,
853 Extension(session): Extension<SessionHandle>,
854 Path(parent_id): Path<String>,
855 headers: axum::http::HeaderMap,
856 Form(pairs): Form<Vec<(String, String)>>,
857 rel: Arc<PreparedRelation>,
858) -> Response {
859 let ids: Vec<String> = pairs
860 .iter()
861 .filter(|(k, _)| k == "id")
862 .map(|(_, v)| v.trim().to_string())
863 .filter(|v| !v.is_empty())
864 .collect();
865 let mut owned = Vec::new();
866 for id in &ids {
867 match rel.owned(&state.db, &parent_id, id).await {
868 Ok(true) => owned.push(id.clone()),
869 Ok(false) => {}
870 Err(_) => return render_error(),
871 }
872 }
873 let actor = Actor::from(&user);
874 let outcome = bulk::delete_selected(&state, &rel.bulk, &owned, &actor).await;
875 if outcome.deleted > 0 {
876 let n = outcome.deleted as i64;
877 let message = match &rel.kind {
878 Kind::HasMany { .. } => {
879 laterite_core::tn!("Deleted {n} record.", "Deleted {n} records.", n = n)
880 }
881 Kind::ManyToMany { .. } => {
882 laterite_core::tn!("Removed {n} record.", "Removed {n} records.", n = n)
883 }
884 };
885 session.push_flash(FlashLevel::Success, message);
886 }
887 for message in outcome.refused {
888 session.push_flash(FlashLevel::Error, message);
889 }
890 if outcome.failed > 0 {
891 session.push_flash(FlashLevel::Error, t!("Some records could not be deleted."));
892 }
893 let _ = shell;
895 form::saved_response(form::is_htmx(&headers), &rel.parent_edit(&parent_id))
896}