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 toolbar: crate::ui::Toolbar::section(self.config.title.clone())
586 .button(
587 crate::ui::Button::link(self.new_label(shell), format!("{path}/new")).small(),
588 )
589 .render(shell),
590 html,
591 })
592 }
593}
594
595#[allow(clippy::too_many_arguments)]
597pub(crate) async fn index(
598 State(state): State<AdminState>,
599 Extension(shell): Extension<Shell>,
600 Extension(user): Extension<AuthenticatedUser>,
601 Path(parent_id): Path<String>,
602 Query(params): Query<ListParams>,
603 Query(raw): Query<HashMap<String, String>>,
604 headers: axum::http::HeaderMap,
605 rel: Arc<PreparedRelation>,
606) -> Response {
607 match rel.parent_exists(&state.db, &parent_id).await {
608 Ok(true) => {}
609 Ok(false) => return not_found(),
610 Err(_) => return render_error(),
611 }
612 let path = rel.path(&parent_id);
613 let html = match list::fragment(
614 &state,
615 &rel.list_for(&parent_id),
616 &path,
617 params,
618 &raw,
619 &user,
620 shell.clone(),
621 &format!("lat-rel-{}", rel.config.name),
622 Some(rel.row_action(&path, &shell)),
623 )
624 .await
625 {
626 Ok(html) => html,
627 Err(()) => return render_error(),
628 };
629 if form::is_htmx(&headers) {
630 return axum::response::Html(html.into_string()).into_response();
631 }
632 let page = SectionPage {
635 shell: &shell,
636 back: rel.parent_edit(&parent_id),
637 toolbar: crate::ui::Toolbar::default()
638 .button(crate::ui::Button::link(rel.new_label(&shell), format!("{path}/new")).small())
639 .render(&shell),
640 html,
641 };
642 match crate::html::Markup::from_template(&page) {
643 Ok(body) => shell.page(&rel.config.title, body),
644 Err(_) => render_error(),
645 }
646}
647
648#[derive(Template)]
650#[template(path = "relation_section.html")]
651struct SectionPage<'a> {
652 shell: &'a Shell,
653 back: String,
654 toolbar: crate::html::Markup,
655 html: crate::html::Markup,
656}
657
658pub(crate) async fn new_form(
659 State(state): State<AdminState>,
660 Extension(shell): Extension<Shell>,
661 Path(parent_id): Path<String>,
662 rel: Arc<PreparedRelation>,
663) -> Response {
664 match rel.parent_exists(&state.db, &parent_id).await {
665 Ok(true) => {}
666 Ok(false) => return not_found(),
667 Err(_) => return render_error(),
668 }
669 form::new_form(&state, &rel.new_form_for(&parent_id), shell, Vec::new())
670}
671
672#[allow(clippy::too_many_arguments)]
673pub(crate) async fn create(
674 State(state): State<AdminState>,
675 Extension(shell): Extension<Shell>,
676 Extension(user): Extension<AuthenticatedUser>,
677 Extension(session): Extension<SessionHandle>,
678 Path(parent_id): Path<String>,
679 headers: axum::http::HeaderMap,
680 Form(mut data): Form<HashMap<String, String>>,
681 rel: Arc<PreparedRelation>,
682) -> Response {
683 match rel.parent_exists(&state.db, &parent_id).await {
684 Ok(true) => {}
685 Ok(false) => return not_found(),
686 Err(_) => return render_error(),
687 }
688 match &rel.kind {
690 Kind::HasMany { foreign_key } => {
691 data.insert(foreign_key.clone(), parent_id.clone());
692 }
693 Kind::ManyToMany { pivot, .. } => {
694 data.insert(pivot.parent_key.clone(), parent_id.clone());
695 let related = data
697 .get(&pivot.related_key)
698 .map(|v| v.trim().to_string())
699 .filter(|v| !v.is_empty());
700 if let Some(related) = related {
701 let present = rel
702 .exists(
703 &state.db,
704 &rel.config.entity,
705 &[(&rel.config.list.id_field, &related)],
706 )
707 .await;
708 let attached = rel
709 .exists(
710 &state.db,
711 &pivot.table,
712 &[
713 (&pivot.parent_key, &parent_id),
714 (&pivot.related_key, &related),
715 ],
716 )
717 .await;
718 let refusal = match (present, attached) {
719 (Ok(false), _) => Some(t!("There is no such record to attach.")),
720 (_, Ok(true)) => Some(t!("That record is already attached.")),
721 (Err(_), _) | (_, Err(_)) => return render_error(),
722 _ => None,
723 };
724 if let Some(message) = refusal {
725 session.push_flash(FlashLevel::Error, message);
726 let back = format!("{}/new", rel.path(&parent_id));
727 return form::saved_response(form::is_htmx(&headers), &back);
728 }
729 }
730 }
731 }
732 form::create(
733 &state,
734 &rel.new_form_for(&parent_id),
735 data,
736 shell,
737 &user,
738 &session,
739 &headers,
740 )
741 .await
742}
743
744pub(crate) async fn edit_form(
745 State(state): State<AdminState>,
746 Extension(shell): Extension<Shell>,
747 Path((parent_id, child_id)): Path<(String, String)>,
748 rel: Arc<PreparedRelation>,
749) -> Response {
750 match rel.owned(&state.db, &parent_id, &child_id).await {
751 Ok(true) => {}
752 Ok(false) => return not_found(),
753 Err(_) => return render_error(),
754 }
755 form::edit_form(
756 &state,
757 &rel.form_for(&parent_id),
758 child_id,
759 shell,
760 Vec::new(),
761 )
762 .await
763}
764
765#[allow(clippy::too_many_arguments)]
766pub(crate) async fn update(
767 State(state): State<AdminState>,
768 Extension(shell): Extension<Shell>,
769 Extension(user): Extension<AuthenticatedUser>,
770 Extension(session): Extension<SessionHandle>,
771 Path((parent_id, child_id)): Path<(String, String)>,
772 headers: axum::http::HeaderMap,
773 Form(mut data): Form<HashMap<String, String>>,
774 rel: Arc<PreparedRelation>,
775) -> Response {
776 match &rel.kind {
779 Kind::HasMany { foreign_key } => {
780 match rel.owned(&state.db, &parent_id, &child_id).await {
781 Ok(true) => {}
782 Ok(false) => return not_found(),
783 Err(_) => return render_error(),
784 }
785 data.insert(foreign_key.clone(), parent_id.clone());
786 }
787 Kind::ManyToMany { pivot, .. } => {
788 match rel
789 .pivot_related(&state.db, pivot, &parent_id, &child_id)
790 .await
791 {
792 Ok(Some(related)) => {
793 data.insert(pivot.related_key.clone(), related);
794 }
795 Ok(None) => return not_found(),
796 Err(_) => return render_error(),
797 }
798 data.insert(pivot.parent_key.clone(), parent_id.clone());
799 }
800 }
801 form::update(
802 &state,
803 &rel.form_for(&parent_id),
804 child_id,
805 data,
806 shell,
807 &user,
808 &session,
809 &headers,
810 )
811 .await
812}
813
814#[allow(clippy::too_many_arguments)]
816pub(crate) async fn reorder(
817 State(state): State<AdminState>,
818 Extension(shell): Extension<Shell>,
819 Extension(user): Extension<AuthenticatedUser>,
820 Extension(session): Extension<SessionHandle>,
821 Path(parent_id): Path<String>,
822 headers: axum::http::HeaderMap,
823 Form(pairs): Form<Vec<(String, String)>>,
824 rel: Arc<PreparedRelation>,
825) -> Response {
826 match rel.parent_exists(&state.db, &parent_id).await {
827 Ok(true) => {}
828 Ok(false) => return not_found(),
829 Err(_) => return render_error(),
830 }
831 let path = rel.path(&parent_id);
832 let row_action = Some(rel.row_action(&path, &shell));
833 list::reorder_handler(
834 &state,
835 &rel.list_for(&parent_id),
836 &path,
837 &format!("lat-rel-{}", rel.config.name),
838 row_action,
839 &user,
840 &session,
841 shell,
842 &headers,
843 &pairs,
844 )
845 .await
846}
847
848#[allow(clippy::too_many_arguments)]
851pub(crate) async fn delete(
852 State(state): State<AdminState>,
853 Extension(shell): Extension<Shell>,
854 Extension(user): Extension<AuthenticatedUser>,
855 Extension(session): Extension<SessionHandle>,
856 Path(parent_id): Path<String>,
857 headers: axum::http::HeaderMap,
858 Form(pairs): Form<Vec<(String, String)>>,
859 rel: Arc<PreparedRelation>,
860) -> Response {
861 let ids: Vec<String> = pairs
862 .iter()
863 .filter(|(k, _)| k == "id")
864 .map(|(_, v)| v.trim().to_string())
865 .filter(|v| !v.is_empty())
866 .collect();
867 let mut owned = Vec::new();
868 for id in &ids {
869 match rel.owned(&state.db, &parent_id, id).await {
870 Ok(true) => owned.push(id.clone()),
871 Ok(false) => {}
872 Err(_) => return render_error(),
873 }
874 }
875 let actor = Actor::from(&user);
876 let outcome = bulk::delete_selected(&state, &rel.bulk, &owned, &actor).await;
877 if outcome.deleted > 0 {
878 let n = outcome.deleted as i64;
879 let message = match &rel.kind {
880 Kind::HasMany { .. } => {
881 laterite_core::tn!("Deleted {n} record.", "Deleted {n} records.", n = n)
882 }
883 Kind::ManyToMany { .. } => {
884 laterite_core::tn!("Removed {n} record.", "Removed {n} records.", n = n)
885 }
886 };
887 session.push_flash(FlashLevel::Success, message);
888 }
889 for message in outcome.refused {
890 session.push_flash(FlashLevel::Error, message);
891 }
892 if outcome.failed > 0 {
893 session.push_flash(FlashLevel::Error, t!("Some records could not be deleted."));
894 }
895 let _ = shell;
897 form::saved_response(form::is_htmx(&headers), &rel.parent_edit(&parent_id))
898}