use tablo_ui::{input as ui_input, select as ui_select};
use toasty::stmt::Expr;
use topcoat::{Result, context::Cx, view::*};
use super::{
super::relationship::{OptionLoadError, RelatedCheck},
Field, Placement, render_field,
};
use crate::values::FormValue;
mod check;
mod listed;
mod relationship;
pub(crate) use listed::Layout;
pub(crate) use relationship::Relationship;
#[derive(Clone, Default)]
pub(crate) struct ChoiceControl {
pub(super) searchable: bool,
pub(super) discriminant: bool,
pub(super) options: Vec<(String, crate::Text)>,
pub(super) relationship: Option<Relationship>,
pub(super) parent: Option<Parent>,
pub(super) multiple: bool,
pub(super) bounds: super::Items,
pub(super) layout: Layout,
}
#[derive(Clone)]
pub(crate) struct Parent {
pub(super) watched: String,
pub(super) model: std::any::TypeId,
pub(super) column: Option<String>,
pub(super) scope: ParentScope,
}
pub(crate) type ParentScope = std::sync::Arc<dyn Fn(&str) -> Option<Expr<bool>> + Send + Sync>;
enum Scope {
All,
Within(Expr<bool>),
Nothing,
}
pub(crate) fn option_view<'a>(
cx: &'a Cx,
value: String,
label: String,
selected: bool,
) -> BoxView<'a> {
view! { cx => <option value=(value) selected=(selected)>(label)</option> }.boxed()
}
impl ChoiceControl {
pub(crate) fn is_searchable(&self) -> bool {
self.searchable
}
pub(crate) fn label_of(&self, value: &str) -> Option<&crate::Text> {
self.options
.iter()
.find(|(stored, _)| stored == value)
.map(|(_, label)| label)
}
fn spelled(&self, cx: &Cx) -> Vec<(String, String)> {
self.options
.iter()
.map(|(value, label)| (value.clone(), label.localize(cx)))
.collect()
}
pub(crate) fn offers_nothing(&self) -> bool {
self.options.is_empty() && self.relationship.is_none()
}
pub(crate) fn is_relationship(&self) -> bool {
self.relationship.is_some()
}
pub(crate) fn is_multiple(&self) -> bool {
self.multiple
}
pub(crate) fn is_listed(&self) -> bool {
self.multiple || self.layout != Layout::Select
}
pub(crate) fn source_model(&self) -> Option<crate::toasty_compat::model::ModelId> {
self.relationship
.as_ref()
.map(|relationship| relationship.model_id)
}
pub(crate) fn unmasked_model(
&self,
is_mounted: impl Fn(std::any::TypeId) -> bool,
restricted: impl Fn(crate::toasty_compat::model::ModelId) -> bool,
) -> Option<&'static str> {
self.relationship
.as_ref()?
.unmasked_model(is_mounted, restricted)
}
pub(crate) fn narrowed_by(
&self,
restricts: impl Fn(crate::toasty_compat::model::ModelId, &str) -> bool,
) -> Option<&str> {
let column = self.parent.as_ref()?.column.as_deref()?;
let model = self.relationship.as_ref()?.model_id;
restricts(model, column).then_some(column)
}
pub(crate) fn parent_key(&self) -> Option<&str> {
self.parent.as_ref().map(|parent| parent.watched.as_str())
}
pub(crate) fn misdeclared_parent(&self) -> bool {
self.parent.as_ref().is_some_and(|parent| {
self.relationship
.as_ref()
.is_none_or(|relationship| relationship.model != parent.model)
})
}
fn scope(&self, parent: Option<&str>) -> Scope {
let Some(declared) = &self.parent else {
return Scope::All;
};
let value = parent.unwrap_or("").trim();
if value.is_empty() {
return Scope::Nothing;
}
match (declared.scope)(value) {
Some(expr) => Scope::Within(expr),
None => Scope::Nothing,
}
}
pub(crate) fn has_composite_source(&self) -> bool {
self.relationship
.as_ref()
.is_some_and(|relationship| relationship.composite)
}
pub(crate) fn tenant_scoped_model(
&self,
cx: &Cx,
) -> Option<crate::toasty_compat::model::ModelId> {
self.relationship
.as_ref()
.and_then(|relationship| (relationship.tenant_scoped_model)(cx))
}
pub(crate) fn unavailable_source(&self, cx: &Cx) -> Option<&'static str> {
self.relationship
.as_ref()
.and_then(|relationship| relationship.unavailable_source(cx))
}
pub(crate) async fn search_options(
&self,
cx: &Cx,
q: &str,
parent: Option<&str>,
) -> Result<Vec<(String, String)>, OptionLoadError> {
let Some(relationship) = &self.relationship else {
return Ok(self.spelled(cx));
};
match self.scope(parent) {
Scope::All => (relationship.search)(cx, q.to_string(), None).await,
Scope::Within(scope) => (relationship.search)(cx, q.to_string(), Some(scope)).await,
Scope::Nothing => Ok(Vec::new()),
}
}
async fn load_options(
&self,
cx: &Cx,
parent: Option<&str>,
) -> Result<Vec<(String, String)>, OptionLoadError> {
match (&self.relationship, self.scope(parent)) {
(Some(relationship), Scope::All) => (relationship.load)(cx).await,
(Some(relationship), Scope::Within(scope)) => {
(relationship.search)(cx, String::new(), Some(scope)).await
}
(Some(_), Scope::Nothing) => Ok(Vec::new()),
(None, _) => Ok(self.spelled(cx)),
}
}
pub(super) async fn keep_unseen(
&self,
cx: &Cx,
value: &FormValue,
stored: &FormValue,
ex: &mut dyn toasty::Executor,
) -> Option<FormValue> {
let relationship = self.relationship.as_ref().filter(|_| self.multiple)?;
let mut keys = value.as_list()?.to_vec();
let dropped: Vec<String> = stored
.as_list()?
.iter()
.filter(|key| !keys.contains(key))
.cloned()
.collect();
let before = keys.len();
for key in dropped {
let seen = matches!(
(relationship.check)(cx, key.clone(), None, &mut *ex).await,
Ok(RelatedCheck::FoundViewable)
);
if !seen {
keys.push(key);
}
}
(keys.len() != before).then_some(FormValue::List(keys))
}
}
impl Field {
pub(super) async fn render_choice<'a>(
&self,
choice: &ChoiceControl,
cx: &'a Cx,
value: Option<&FormValue>,
error: Option<&crate::values::FieldError>,
id: String,
placement: Placement<'_>,
) -> Result<BoxView<'a>> {
let disabled = self.is_disabled_at(&placement);
if choice.multiple {
let chosen = value.and_then(FormValue::as_list).unwrap_or_default();
return self
.render_choices(choice, cx, chosen, error, id, disabled)
.await;
}
if choice.is_listed() {
let chosen: Vec<String> = value
.and_then(FormValue::as_text)
.map(str::trim)
.filter(|chosen| !chosen.is_empty())
.map(str::to_string)
.into_iter()
.collect();
return self
.render_choices(choice, cx, &chosen, error, id, disabled)
.await;
}
let value = value.and_then(FormValue::as_text);
let parent = placement.parent;
let name = self.name().to_string();
let required = self.required && !disabled;
let searchable = choice.searchable;
let current = value.unwrap_or("").trim().to_string();
let loaded = choice.load_options(cx, parent).await;
let denied = matches!(&loaded, Err(OptionLoadError::Denied));
let overflowed = matches!(&loaded, Err(OptionLoadError::Overflow));
let overflow_searchable = overflowed && searchable && choice.is_relationship();
let keep_current_value = matches!(
&loaded,
Err(OptionLoadError::LoadFailed)
| Err(OptionLoadError::Overflow)
| Err(OptionLoadError::Misdeclared)
);
let mut options = loaded.unwrap_or_default();
if keep_current_value && !current.is_empty() && !options.iter().any(|(v, _)| v == ¤t)
{
options.push((current.clone(), current.clone()));
}
let chrome = self.chrome(
cx,
id.clone(),
error,
denied.then(|| {
crate::t!(
cx,
"tablo-field-unavailable",
label = self.label().to_owned()
)
}),
);
let mut option_views: Vec<BoxView<'a>> = vec![option_view(
cx,
String::new(),
crate::t!(cx, "tablo-field-select"),
current.is_empty(),
)];
for (val, lab) in &options {
option_views.push(option_view(cx, val.clone(), lab.clone(), current == *val));
}
let list_id = format!("{id}-options-list");
let filter_label = crate::t!(
cx,
"tablo-field-filter-options",
label = self.label().to_owned()
);
let dependent = choice.parent_key().map(str::to_string);
let parent_value = dependent
.as_ref()
.map(|_| parent.unwrap_or("").trim().to_string());
let options_field = (overflow_searchable || dependent.is_some()).then(|| name.clone());
let options_server = overflow_searchable.then_some("true");
let options_url = options_field
.as_ref()
.and(placement.options)
.map(str::to_string);
let overflow_hint = crate::t!(cx, "tablo-field-too-many-options");
let hint = overflow_searchable || (searchable && choice.parent_key().is_some());
let hint_hidden = !overflow_searchable;
let aria_invalid = chrome.aria_invalid();
let described_by = chrome.described_by();
let control = view! {
cx =>
if searchable {
<div class="relative" data-options-combobox="">
ui_input(
attrs: attributes! {
type="search"
role="combobox"
aria-expanded="false"
aria-controls=(list_id.clone())
aria-autocomplete="list"
aria-label=(filter_label.clone())
placeholder=(crate::t!(cx, "tablo-field-filter"))
data-options-filter=""
class="h-9"
autocomplete="off"
disabled=(disabled)
}
)
<ul
id=(list_id.clone())
data-options-list=""
role="listbox"
aria-label=(filter_label.clone())
hidden=""
class="absolute z-20 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-border bg-popover p-1 text-sm text-popover-foreground shadow-sm"
></ul>
</div>
}
if hint {
<div
class="text-xs text-muted-foreground"
data-options-hint=""
hidden=(hint_hidden)
>
(overflow_hint)
</div>
}
ui_select(
attrs: attributes! {
id=(id)
name=(name.clone())
required=(required)
disabled=(disabled)
aria-required=(required.then_some("true"))
aria-invalid=(aria_invalid)
aria-describedby=(described_by)
},
for opt in option_views {
(opt)
}
)
}
.boxed();
render_field(
cx,
&chrome,
&self.label(),
required,
attributes! {
cx =>
data-select-filterable=""
data-options-field=(options_field)
data-options-url=(options_url)
data-options-server=(options_server)
data-options-parent=(dependent)
data-options-parent-value=(parent_value)
data-options-empty-text=(crate::t!(cx, "tablo-field-no-options"))
data-options-unmatched-text=(crate::t!(
cx,
"tablo-field-no-matching-options",
))
data-options-searching-text=(crate::t!(cx, "tablo-field-searching"))
},
control,
)
}
}
#[cfg(test)]
mod tests;