Skip to main content

laterite_admin/
relation.rs

1//! Related records on a form: a resource declares which other entity's rows
2//! belong to each of its records, and the edit page carries a section for them.
3//!
4//! A section is the list engine scoped to the parent, with its own New, Edit
5//! and Delete; a related record's form is the form engine mounted under the
6//! parent's path, its foreign key forced from the URL rather than trusted from
7//! the client. A many-to-many relation lists the records a pivot table joins
8//! to the parent: Attach picks one and writes the pivot row, Edit changes
9//! what the pivot row carries, Remove deletes the pivot row and leaves the
10//! record. Nothing binds before the parent exists: a create page names the
11//! sections it will have, and "Save and continue editing" lands on them.
12
13use 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/// One relation a resource declares, listed and edited on the parent's edit
33/// page: rows of `entity` whose `foreign_key` holds the parent's id (has-many),
34/// or rows of `entity` a `pivot` table joins to the parent (many-to-many).
35#[derive(Clone, Debug, Serialize, Deserialize)]
36#[serde(deny_unknown_fields, default)]
37#[non_exhaustive]
38pub struct RelationConfig {
39    /// The relation's name: the key in a descriptor, the path segment under
40    /// the parent (`/products/7/variants`). Not `new`, `edit`, `delete`,
41    /// `import`, `export` or `columns`, which the parent's own routes use.
42    pub name: String,
43    /// The section heading, plural (`Variants`). Defaults to the name,
44    /// humanized.
45    pub title: Text,
46    /// One related record, singular (`Variant`), for the New or Attach button
47    /// and the child form's title.
48    pub label: Text,
49    /// The related table.
50    pub entity: String,
51    /// Has-many: the column on the related table holding the parent's id.
52    pub foreign_key: Option<String>,
53    /// Many-to-many: the table joining the parent to the related table.
54    pub pivot: Option<PivotConfig>,
55    /// The section's list over the related table.
56    pub list: ListConfig,
57    /// Has-many: the related record's form; the foreign key is written by the
58    /// framework, and a field of that name here is kept hidden. Many-to-many:
59    /// the fields kept on the pivot row, asked for on Attach and editable from
60    /// the section; none means a membership carries nothing of its own.
61    pub form: FormConfig,
62    /// A permission the operator must hold for the section and its pages,
63    /// besides the parent's. `None` asks for the parent's alone.
64    pub permission: Option<String>,
65}
66
67/// The table joining a many-to-many relation's two sides, one row per
68/// membership.
69#[derive(Clone, Debug, Serialize, Deserialize)]
70#[serde(deny_unknown_fields, default)]
71#[non_exhaustive]
72pub struct PivotConfig {
73    /// The pivot table.
74    pub table: String,
75    /// The column holding the parent's id.
76    pub parent_key: String,
77    /// The column holding the related record's id.
78    pub related_key: String,
79    /// The picker source offering records to attach: a registered
80    /// [`PickerSourceReg`](crate::picker::PickerSourceReg) name.
81    pub source: String,
82    /// The pivot row's id column. Default `id`.
83    pub id_field: String,
84}
85
86impl PivotConfig {
87    /// A pivot table, with records to attach chosen from `source`.
88    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    /// The pivot row's id column.
104    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    /// A has-many relation: rows of `entity` whose `foreign_key` names the
118    /// parent, listed by `list` and edited through `form`.
119    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    /// A many-to-many relation: rows of `entity` that `pivot` joins to the
142    /// parent, listed by `list`. Fields kept on the pivot row go on [`form`].
143    ///
144    /// [`form`]: RelationConfig::form
145    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    /// The fields kept on a many-to-many relation's pivot row.
167    pub fn form(mut self, form: FormConfig) -> Self {
168        self.form = form;
169        self
170    }
171
172    /// The section heading.
173    pub fn title(mut self, title: impl Into<Text>) -> Self {
174        self.title = title.into();
175        self
176    }
177
178    /// A permission the operator must also hold.
179    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
213/// The names a relation may not take: the parent's own routes under `{id}`.
214pub(crate) const RESERVED: [&str; 6] = ["new", "edit", "delete", "import", "export", "columns"];
215
216/// How a relation's rows are tied to the parent.
217enum Kind {
218    HasMany {
219        foreign_key: String,
220    },
221    ManyToMany {
222        pivot: PivotConfig,
223        /// The Attach form: the pivot fields plus a picker for the record.
224        attach: Arc<PreparedForm>,
225    },
226}
227
228/// A relation resolved at boot: its forms prepared, its delete context built,
229/// ready to be scoped to a parent per request.
230pub(crate) struct PreparedRelation {
231    pub config: RelationConfig,
232    /// The parent's path under the admin mount (`/admin/products`).
233    pub parent_base: String,
234    pub parent_entity: String,
235    pub parent_id_field: String,
236    kind: Kind,
237    /// Has-many: the child form. Many-to-many: the pivot row's form.
238    form: Arc<PreparedForm>,
239    bulk: Arc<BulkContext>,
240}
241
242/// Keeps a field of this name out of sight: a declared one is hidden, an
243/// undeclared one is added hidden so the submission carries it.
244fn 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                // The foreign key is the framework's to write.
286                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                // Attach asks which record, through the picker; afterwards the
308                // membership is fixed and the form hides it.
309                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    /// The section's path under a parent record.
354    pub(crate) fn path(&self, parent_id: &str) -> String {
355        format!("{}/{}/{}", self.parent_base, parent_id, self.config.name)
356    }
357
358    /// The parent's edit page, where a save returns to.
359    fn parent_edit(&self, parent_id: &str) -> String {
360        format!("{}/{}/edit", self.parent_base, parent_id)
361    }
362
363    /// A form mounted under this parent.
364    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    /// The form behind New (has-many) or Attach (many-to-many).
372    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    /// The form behind a row's Edit.
380    fn form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
381        self.rebase(&self.form, parent_id)
382    }
383
384    /// The section's list scoped to this parent.
385    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                // A section is one parent's rows, never a tree of its own.
393                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                // The order would be the pivot's to hold, and the list is not
401                // over the pivot.
402                list.reorder = None;
403                // A membership with nothing of its own has nothing to edit.
404                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    /// Whether the section's rows keep an order of their own: a has-many
420    /// whose list declares `reorder`.
421    pub(crate) fn reorderable(&self) -> bool {
422        matches!(self.kind, Kind::HasMany { .. }) && self.config.list.reorder.is_some()
423    }
424
425    /// The per-row action: Delete for a record, Remove for a membership.
426    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    /// The New or Attach button's label.
439    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    /// Whether one row of `table` has every column equal to its value.
448    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    /// Whether the parent record exists; a child page under a parent that does
474    /// not is a 404, never a write with a dangling key.
475    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    /// Whether `child_id` is this parent's: a has-many's related record, or a
485    /// many-to-many's pivot row. A child page under the wrong parent is a 404,
486    /// so an id cannot be walked across parents.
487    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    /// The related record a pivot row names, when the row is this parent's.
512    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    /// The section as the parent's edit page shows it: page one, unsorted.
550    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/// The handlers, each over one relation under one parent.
586#[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    // Without scripting, a sort or pager link lands here: the section on a
623    // page of its own, under the parent.
624    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/// A section on a page of its own, for a request without scripting.
638#[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    // The parent is the URL's, whatever the submission said.
679    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            // A blank is the form's to refuse, as a required field.
686            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    // The parent is the URL's and the membership is the row's, whatever the
767    // submission said.
768    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/// Moves one related record within its parent's section.
805#[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/// Removes the selected rows (a has-many's records, a many-to-many's
839/// memberships), then shows the parent page again.
840#[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    // Back to the parent page whole, so the flash saying what happened shows.
886    let _ = shell;
887    form::saved_response(form::is_htmx(&headers), &rel.parent_edit(&parent_id))
888}