use super::controls::Slot;
use crate::action::{Cursor, Page};
use crate::i18n::Text;
use crate::render::{Render, attr};
use crate::value::{FieldKind, FieldType, SupportsRequired, Value};
use serde::{Deserialize, Serialize};
pub const MAX_LOOKUP_PAGE: u32 = 50;
pub const MAX_LOOKUP_IDS: usize = 100;
pub const MAX_LOOKUP_COLUMNS: usize = 6;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct LookupQuery {
#[serde(default)]
pub text: String,
#[serde(default)]
pub ids: Vec<i64>,
#[serde(default)]
pub offset: u64,
#[serde(default)]
pub limit: u32,
}
impl LookupQuery {
pub fn limit(&self) -> u32 {
self.limit.clamp(1, MAX_LOOKUP_PAGE)
}
pub fn ids(&self) -> &[i64] {
&self.ids[..self.ids.len().min(MAX_LOOKUP_IDS)]
}
pub fn apply(&self, items: Vec<LookupItem>) -> Page<LookupItem> {
let needle = fold(self.text.trim());
let matching: Vec<LookupItem> = if !self.ids.is_empty() {
items.into_iter().filter(|i| self.ids().contains(&i.id)).collect()
} else {
items
.into_iter()
.filter(|i| {
needle.is_empty()
|| fold(&i.label).contains(&needle)
|| i.hint.as_deref().is_some_and(|h| fold(h).contains(&needle))
|| i.cells.iter().any(|c| fold(c).contains(&needle))
})
.collect()
};
let total = matching.len();
let start = usize::try_from(self.offset).unwrap_or(usize::MAX).min(total);
let size = if self.ids.is_empty() { self.limit() as usize } else { MAX_LOOKUP_IDS };
let end = start.saturating_add(size).min(total);
let page = Page::new(matching[start..end].to_vec()).total(total as u64);
if end < total { page.next(Cursor(end.to_string())) } else { page }
}
}
fn fold(s: &str) -> String {
s.chars()
.flat_map(char::to_lowercase)
.map(|c| match c {
'ı' | 'i' | '\u{307}' => 'i',
c => c,
})
.filter(|c| *c != '\u{307}')
.collect()
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LookupItem {
pub id: i64,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cells: Vec<String>,
}
impl LookupItem {
pub fn new(id: i64, label: impl Into<String>) -> Self {
Self { id, label: label.into(), hint: None, cells: Vec::new() }
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn cells<I>(mut self, cells: I) -> Self
where
I: IntoIterator,
I::Item: Into<String>,
{
self.cells = cells.into_iter().take(MAX_LOOKUP_COLUMNS).map(Into::into).collect();
self
}
}
#[diagnostic::on_unimplemented(
message = "`{Self}` cannot be filled by a lookup",
label = "not an id field",
note = "`Form::lookup` works on entity ids (such as `CustomerId`), i64 and their Option forms; use `Form::lookup_many` with a `Vec<i64>` field for several values"
)]
pub trait LookupValue {}
impl LookupValue for i64 {}
impl<T: LookupValue> LookupValue for Option<T> {}
impl FieldType for Vec<i64> {
const KIND: FieldKind = FieldKind::Custom { editor: "ids" };
fn to_value(&self) -> Value {
Value::Text(self.iter().map(i64::to_string).collect::<Vec<_>>().join(","))
}
fn __is_empty(&self) -> bool {
self.is_empty()
}
}
impl SupportsRequired for Vec<i64> {}
pub(crate) fn parse_ids(text: &str) -> Option<Vec<i64>> {
let ids: Option<Vec<i64>> =
text.split(',').map(str::trim).filter(|p| !p.is_empty()).map(|p| p.parse().ok()).collect();
ids.filter(|v| v.len() <= 1000)
}
pub(crate) fn render_lookup(out: &mut String, r: &Render, s: &Slot, multiple: bool, columns: &[Text]) {
let value = match s.value {
Value::Int(n) => n.to_string(),
Value::Text(t) => t.clone(),
_ => String::new(),
};
let t = |k: &str| r.i18n.text(r.locale, &Text::key(format!("zeyn.ui.lookup.{k}")));
let texts = serde_json::json!({
"open": t("open"),
"no_results": t("no_results"),
"results": t("results"),
"remove": t("remove"),
"more": t("more"),
"selected": t("selected"),
"loading": r.i18n.text(r.locale, &Text::key("zeyn.ui.loading")),
"clear": r.i18n.text(r.locale, &Text::key("zeyn.ui.date.clear")),
});
let columns = if columns.is_empty() {
String::new()
} else {
let headers: Vec<String> = columns.iter().map(|c| r.i18n.text(r.locale, c)).collect();
format!(r#" data-columns="{}""#, attr(&headers))
};
out.push_str(s.label);
out.push_str(&format!(
r#"<div class="z-lookup" data-zeyn-lookup="{}" data-multiple="{multiple}"{columns} data-texts="{}"><input type="text" class="z-lookup-value" inputmode="numeric" autocomplete="off" {} value="{}"></div>"#,
s.m.name,
attr(&texts),
s.common,
crate::render::escape(&value)
));
}