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::{
MAX_RELATIONSHIP_OPTIONS, OptionLoadError, OptionSource, RelatedCheck, RelationshipChecker,
RelationshipLoader, RelationshipSearchLoader, related_record_check, related_records,
related_records_search,
},
Field, Placement, render_field,
};
use crate::{BoxFuture, form::FormValue};
mod multiple;
#[derive(Clone, Default)]
pub(crate) struct ChoiceControl {
pub(super) searchable: bool,
pub(super) discriminant: bool,
pub(super) options: Vec<(String, String)>,
pub(super) relationship: Option<Relationship>,
pub(super) parent: Option<Parent>,
pub(super) multiple: bool,
}
#[derive(Clone)]
pub(crate) struct Parent {
pub(super) watched: String,
pub(super) model: std::any::TypeId,
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,
}
#[derive(Clone)]
pub(crate) struct Relationship {
load: RelationshipLoader,
search: RelationshipSearchLoader,
check: RelationshipChecker,
source: &'static str,
model: std::any::TypeId,
model_id: crate::toasty_compat::model::ModelId,
available: fn(&Cx) -> bool,
tenant_scoped_model: fn(&Cx) -> Option<crate::toasty_compat::model::ModelId>,
composite: bool,
}
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 Relationship {
pub(crate) fn new<R>(label: impl Fn(&Cx, &R::Model) -> String + Send + Sync + 'static) -> Self
where
R: OptionSource + 'static,
{
let project = std::sync::Arc::new(move |cx: &Cx, record: &R::Model| {
(crate::toasty_compat::pk::pk_text(record), label(cx, record))
});
let search_project = project.clone();
let load = std::sync::Arc::new(move |cx: &Cx| {
let project = project.clone();
let cx = cx.clone();
Box::pin(async move {
let records = related_records::<R>(&cx, crate::tenancy::tenant_id(&cx))
.await
.map_err(|error| error.clone())?;
Ok(records.iter().map(|record| project(&cx, record)).collect())
}) as BoxFuture<'static, _>
}) as RelationshipLoader;
let search = std::sync::Arc::new(move |cx: &Cx, q: String, scope: Option<Expr<bool>>| {
let project = search_project.clone();
let cx = cx.clone();
Box::pin(async move {
let records = related_records_search::<R>(&cx, q, scope)
.await
.map_err(|error| error.clone())?;
Ok(records.iter().map(|record| project(&cx, record)).collect())
}) as BoxFuture<'static, _>
}) as RelationshipSearchLoader;
let check = std::sync::Arc::new(check_record::<R>) as RelationshipChecker;
Self {
load,
search,
check,
source: std::any::type_name::<R>(),
model: std::any::TypeId::of::<R::Model>(),
model_id: <R::Model as toasty::schema::Model>::id(),
available: R::available,
tenant_scoped_model: |cx| {
R::requires_tenant(cx).then(<R::Model as toasty::schema::Model>::id)
},
composite: crate::toasty_compat::pk::pk_is_composite::<R::Model>(),
}
}
pub(crate) fn load(&self) -> RelationshipLoader {
self.load.clone()
}
pub(crate) fn has_composite_source(&self) -> bool {
self.composite
}
pub(crate) fn unavailable_source(&self, cx: &Cx) -> Option<&'static str> {
(!(self.available)(cx)).then_some(self.source)
}
}
fn check_record<'a, R>(
cx: &'a Cx,
value: String,
scope: Option<Expr<bool>>,
ex: &'a mut dyn toasty::Executor,
) -> BoxFuture<'a, Result<RelatedCheck, OptionLoadError>>
where
R: OptionSource,
{
Box::pin(related_record_check::<R>(cx, value, scope, ex))
}
impl ChoiceControl {
pub(crate) fn is_searchable(&self) -> bool {
self.searchable
}
pub(crate) fn label_of(&self, value: &str) -> Option<&str> {
self.options
.iter()
.find(|(stored, _)| stored == value)
.map(|(_, label)| label.as_str())
}
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 source_model(&self) -> Option<crate::toasty_compat::model::ModelId> {
self.relationship
.as_ref()
.map(|relationship| relationship.model_id)
}
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.options.clone());
};
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.options.clone()),
}
}
pub(super) async fn validate_exists(
&self,
cx: &Cx,
label: &str,
value: &FormValue,
parent: Option<&str>,
stored: Option<&FormValue>,
) -> Vec<String> {
if !self.multiple {
let Some(value) = value.as_text() else {
return Vec::new();
};
return self.validate_one(cx, label, value, parent).await;
}
let held = stored.and_then(FormValue::as_list).unwrap_or_default();
let keys = match chosen(label, value, held) {
Ok(keys) => keys,
Err(message) => return vec![message],
};
for key in keys.iter().filter(|key| !held.contains(key)) {
let errors = self.validate_one(cx, label, key, parent).await;
if !errors.is_empty() {
return errors;
}
}
Vec::new()
}
async fn validate_one(
&self,
cx: &Cx,
label: &str,
value: &str,
parent: Option<&str>,
) -> Vec<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Vec::new();
}
let Some(relationship) = &self.relationship else {
if !self.options.is_empty() && !self.options.iter().any(|(v, _)| v == trimmed) {
return vec![format!("{label} is invalid")];
}
return Vec::new();
};
let scope = match self.scope(parent) {
Scope::All => None,
Scope::Within(scope) => Some(scope),
Scope::Nothing => return vec![format!("{label} is invalid")],
};
let failure = match self.load_options(cx, parent).await {
Ok(opts) if opts.iter().any(|(v, _)| v == trimmed) => return Vec::new(),
Ok(_) => "is invalid",
Err(OptionLoadError::Denied) => "is not available",
Err(OptionLoadError::Overflow) if self.searchable => {
let mut db = crate::db::db(cx);
match (relationship.check)(cx, trimmed.to_string(), scope, &mut db).await {
Ok(RelatedCheck::FoundViewable) => return Vec::new(),
Ok(RelatedCheck::FoundHidden | RelatedCheck::NotFound) => "is invalid",
Err(error) => load_failure(&error),
}
}
Err(error) => load_failure(&error),
};
vec![format!("{label} {failure}")]
}
pub(super) async fn recheck(
&self,
cx: &Cx,
label: &str,
value: &FormValue,
parent: Option<&str>,
stored: Option<&FormValue>,
ex: &mut dyn toasty::Executor,
) -> Vec<String> {
if !self.multiple {
let Some(value) = value.as_text() else {
return Vec::new();
};
return self.recheck_one(cx, label, value, parent, ex).await;
}
let held = stored.and_then(FormValue::as_list).unwrap_or_default();
let keys = match chosen(label, value, held) {
Ok(keys) => keys,
Err(message) => return vec![message],
};
for key in keys.iter().filter(|key| !held.contains(key)) {
let errors = self.recheck_one(cx, label, key, parent, &mut *ex).await;
if !errors.is_empty() {
return errors;
}
}
Vec::new()
}
async fn recheck_one(
&self,
cx: &Cx,
label: &str,
value: &str,
parent: Option<&str>,
ex: &mut dyn toasty::Executor,
) -> Vec<String> {
let trimmed = value.trim();
let Some(relationship) = &self.relationship else {
return Vec::new();
};
if trimmed.is_empty() {
return Vec::new();
}
let scope = match self.scope(parent) {
Scope::All => None,
Scope::Within(scope) => Some(scope),
Scope::Nothing => return vec![format!("{label} is invalid")],
};
let failure = match (relationship.check)(cx, trimmed.to_string(), scope, ex).await {
Ok(RelatedCheck::FoundViewable) => return Vec::new(),
Ok(RelatedCheck::FoundHidden | RelatedCheck::NotFound) => "is invalid",
Err(error) => load_failure(&error),
};
vec![format!("{label} {failure}")]
}
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))
}
}
fn chosen<'v>(
label: &str,
value: &'v FormValue,
held: &[String],
) -> std::result::Result<&'v [String], String> {
let keys = value.as_list().unwrap_or_default();
let added = keys.iter().filter(|key| !held.contains(key)).count();
if added > MAX_RELATIONSHIP_OPTIONS {
return Err(format!(
"{label} takes at most {MAX_RELATIONSHIP_OPTIONS} new choices"
));
}
Ok(keys)
}
fn load_failure(error: &OptionLoadError) -> &'static str {
match error {
OptionLoadError::Denied => "is not available",
OptionLoadError::LoadFailed | OptionLoadError::Overflow => "could not load options, retry",
OptionLoadError::Misdeclared => "could not load options",
}
}
impl Field {
pub(super) async fn render_choice<'a>(
&self,
choice: &ChoiceControl,
cx: &'a Cx,
value: Option<&FormValue>,
error: Option<&crate::form::FieldError>,
id: String,
placement: Placement<'_>,
) -> Result<BoxView<'a>> {
let disabled = self.is_disabled(placement.editing);
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;
}
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(
id.clone(),
error,
denied.then(|| format!("{} is not available", self.label_str())),
);
let mut option_views: Vec<BoxView<'a>> = vec![option_view(
cx,
String::new(),
"-- Select --".to_string(),
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 = format!("Filter {} options", self.label_str());
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 = "Too many options — type to search".to_string();
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="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_str(),
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)
},
control,
)
}
}
#[cfg(test)]
mod tests;