Skip to main content

laterite_admin/medialib/
field.rs

1//! The `media` field: a file (or files) chosen from the library, stored by
2//! id, shown as a tile, picked in a dialog over the library's own browser.
3
4use askama::Template;
5use serde::{Deserialize, Serialize};
6
7use crate::field::{
8    FieldCx, FieldRegistry, FieldType, FieldVm, OptionsError, ResolvedOptions, SubmittedField,
9};
10use crate::html::Markup;
11use laterite_core::validation::Mode;
12use laterite_core::AttrValue;
13
14/// What the picker offers.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum MediaMode {
18    /// Any file in the library.
19    #[default]
20    File,
21    /// Images alone, shown as thumbnails.
22    Image,
23}
24
25#[derive(Debug, Deserialize, Default)]
26struct MediaOptions {
27    #[serde(default)]
28    mode: MediaMode,
29    /// How many files the field holds: `1` (the default) stores one id; any
30    /// other number stores a JSON list, `0` meaning no limit.
31    #[serde(default = "one")]
32    max: u32,
33}
34
35fn one() -> u32 {
36    1
37}
38
39#[derive(Debug, Clone)]
40pub(crate) struct MediaResolved {
41    mode: MediaMode,
42    max: u32,
43}
44
45#[derive(Serialize, Deserialize, Default)]
46struct MediaData {
47    pick_url: String,
48    resolve_url: String,
49    mode: MediaMode,
50    max: u32,
51    /// The widget's words, in the operator's locale.
52    select: String,
53    replace: String,
54    clear: String,
55    use_selected: String,
56    title: String,
57}
58
59#[derive(Template)]
60#[template(path = "fields/media.html")]
61struct MediaTmpl<'a> {
62    name: &'a str,
63    id: &'a str,
64    value: &'a str,
65    pick_url: &'a str,
66    resolve_url: &'a str,
67    mode: &'a str,
68    max: u32,
69    multiple: bool,
70    words: &'a MediaData,
71}
72
73/// The `media` field type. Options: `mode: image | file` and `max`.
74pub struct MediaField;
75
76/// The ids in a control's value: `"3,7"` or a stored JSON list `[3, 7]`.
77fn ids_in(value: &str) -> Vec<i64> {
78    let value = value.trim();
79    if let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(value) {
80        return items.iter().filter_map(|v| v.as_i64()).collect();
81    }
82    value
83        .split(',')
84        .filter_map(|p| p.trim().parse::<i64>().ok())
85        .collect()
86}
87
88impl FieldType for MediaField {
89    fn view_key(&self) -> &'static str {
90        "media"
91    }
92
93    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
94        Some(vec!["mode".to_string(), "max".to_string()])
95    }
96
97    fn resolve_options(
98        &self,
99        raw: &serde_json::Value,
100        _types: &FieldRegistry,
101    ) -> Result<ResolvedOptions, OptionsError> {
102        let opts: MediaOptions = if raw.is_null() {
103            MediaOptions::default()
104        } else {
105            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
106        };
107        Ok(ResolvedOptions::new(MediaResolved {
108            mode: opts.mode,
109            max: opts.max,
110        }))
111    }
112
113    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
114        let resolved = cx.opts.get::<MediaResolved>();
115        let data = MediaData {
116            pick_url: format!("{}/media/pick", cx.base),
117            resolve_url: format!("{}/media/resolve", cx.base),
118            mode: resolved.map(|r| r.mode).unwrap_or_default(),
119            max: resolved.map(|r| r.max).unwrap_or(1),
120            select: cx.i18n.t(&laterite_core::t!("Select")),
121            replace: cx.i18n.t(&laterite_core::t!("Replace")),
122            clear: cx.i18n.t(&laterite_core::t!("Clear")),
123            use_selected: cx.i18n.t(&laterite_core::t!("Use selected")),
124            title: cx.i18n.t(&laterite_core::t!("Choose from the library")),
125        };
126        FieldVm {
127            view_key: "media".to_string(),
128            name: cx.name.to_string(),
129            id: cx.id.to_string(),
130            label: cx.label.to_string(),
131            required: cx.required,
132            value: cx.value.clone(),
133            data: serde_json::to_value(data).unwrap_or_default(),
134        }
135    }
136
137    fn render_default(&self, vm: &FieldVm) -> Markup {
138        let data: MediaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
139        let value: String = ids_in(vm.value.as_text())
140            .iter()
141            .map(|i| i.to_string())
142            .collect::<Vec<_>>()
143            .join(",");
144        Markup::from_template(&MediaTmpl {
145            name: &vm.name,
146            id: &vm.id,
147            value: &value,
148            pick_url: &data.pick_url,
149            resolve_url: &data.resolve_url,
150            mode: match data.mode {
151                MediaMode::Image => "image",
152                MediaMode::File => "file",
153            },
154            max: data.max,
155            multiple: data.max != 1,
156            words: &data,
157        })
158        .unwrap_or_default()
159    }
160
161    /// One id as a number (null when cleared); several as a JSON list.
162    fn to_attr(
163        &self,
164        field: &SubmittedField<'_>,
165        opts: &ResolvedOptions,
166        _mode: Mode,
167    ) -> Result<Option<AttrValue>, String> {
168        let max = opts.get::<MediaResolved>().map(|r| r.max).unwrap_or(1);
169        Ok(field.value().map(|v| {
170            let mut ids = ids_in(v);
171            if max > 1 {
172                ids.truncate(max as usize);
173            }
174            if max == 1 {
175                match ids.first() {
176                    Some(id) => AttrValue::Int(*id),
177                    None => AttrValue::Null,
178                }
179            } else {
180                AttrValue::Json(serde_json::Value::Array(
181                    ids.into_iter().map(serde_json::Value::from).collect(),
182                ))
183            }
184        }))
185    }
186
187    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
188        ids_in(stored.unwrap_or_default())
189            .iter()
190            .map(|i| i.to_string())
191            .collect::<Vec<_>>()
192            .join(",")
193    }
194
195    fn linked_media(&self, submitted: Option<&str>, opts: &ResolvedOptions) -> Option<Vec<i64>> {
196        let max = opts.get::<MediaResolved>().map(|r| r.max).unwrap_or(1);
197        let mut ids = ids_in(submitted.unwrap_or_default());
198        if max >= 1 {
199            ids.truncate(max as usize);
200        }
201        Some(ids)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn ids_come_from_a_list_or_a_comma_string() {
211        assert_eq!(ids_in("3, 7"), vec![3, 7]);
212        assert_eq!(ids_in("[3,7]"), vec![3, 7]);
213        assert_eq!(ids_in(""), Vec::<i64>::new());
214        assert_eq!(ids_in("x"), Vec::<i64>::new());
215    }
216}