use crate::action::{Access, Action, ActionMeta, Error, Result};
use crate::ctx::{BoxFuture, Ctx, Identity, Outcome};
use crate::i18n::Text;
use crate::model::{FieldError, Rule, ValidationErrors};
use crate::render::{Render, escape};
use crate::screen::{Component, Effect, FormSource, Node, UiCtx};
use crate::value::{FieldType, Value};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as Json, json};
use std::marker::PhantomData;
use std::sync::Arc;
pub const MAX_COMMENT: usize = 1000;
#[derive(Clone, Debug)]
pub struct Transition<S> {
pub name: &'static str,
pub label: Text,
pub from: S,
pub to: S,
pub permission: &'static str,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TransitionRecord {
pub name: &'static str,
pub from: String,
pub to: String,
pub comment: Option<String>,
}
pub trait WorkflowState: FieldType + Copy + PartialEq + Send + Sync + 'static {}
impl<S: FieldType + Copy + PartialEq + Send + Sync + 'static> WorkflowState for S {}
fn state_text<S: WorkflowState>(state: S) -> String {
match state.to_value() {
Value::Choice(v) => v.to_string(),
other => crate::audit::plain(&other).unwrap_or_default(),
}
}
pub struct Workflow<S> {
transitions: Vec<Transition<S>>,
}
impl<S: WorkflowState> Default for Workflow<S> {
fn default() -> Self {
Workflow { transitions: Vec::new() }
}
}
impl<S: WorkflowState> Workflow<S> {
pub fn new() -> Self {
Self::default()
}
pub fn transition(mut self, name: &'static str, from: S, to: S, permission: &'static str) -> Self {
self.transitions.push(Transition {
name,
label: Text::key_or(format!("workflow.{name}"), name),
from,
to,
permission,
});
self
}
pub fn label(mut self, label: impl Into<Text>) -> Self {
if let Some(t) = self.transitions.last_mut() {
t.label = label.into();
}
self
}
pub fn transitions(&self) -> &[Transition<S>] {
&self.transitions
}
pub fn allowed(&self, identity: &Identity, current: S) -> Vec<&Transition<S>> {
self.transitions
.iter()
.filter(|t| t.from == current && identity.can(&Access::Permission(t.permission)))
.collect()
}
pub fn check(&self) -> std::result::Result<(), Vec<String>> {
let mut problems = Vec::new();
for (i, t) in self.transitions.iter().enumerate() {
let parts: Vec<&str> = t.permission.split('.').collect();
let valid = parts.len() >= 2
&& parts.iter().all(|p| {
!p.is_empty() && p.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
});
if !valid {
problems.push(format!("transition `{}` has an invalid permission `{}`", t.name, t.permission));
}
if self.transitions[..i].iter().any(|o| o.name == t.name && o.from == t.from) {
problems.push(format!("transition `{}` is declared twice from the same state", t.name));
}
}
if problems.is_empty() { Ok(()) } else { Err(problems) }
}
pub fn apply(&self, ctx: &Ctx, current: S, name: &str, comment: Option<&str>) -> Result<S> {
let mut named = self.transitions.iter().filter(|t| t.name == name).peekable();
if named.peek().is_none() {
return Err(Error::NotFound);
}
let t = named.find(|t| t.from == current).ok_or(Error::Conflict)?;
if !ctx.identity().can(&Access::Permission(t.permission)) {
return Err(Error::Forbidden);
}
let comment = comment.map(str::trim).filter(|c| !c.is_empty()).map(str::to_string);
if comment.as_ref().is_some_and(|c| c.chars().count() > MAX_COMMENT) {
let mut errors = ValidationErrors::default();
errors.errors.push(FieldError {
field: "comment",
rule: Rule::MaxLen(MAX_COMMENT),
message: Text::key("zeyn.validation.max_len")
.arg("label", Text::key("zeyn.workflow.comment"))
.arg("n", MAX_COMMENT.to_string()),
});
return Err(Error::Validation(errors));
}
let record = TransitionRecord { name: t.name, from: state_text(t.from), to: state_text(t.to), comment };
ctx.update_trail(|trail| trail.transition = Some(record));
Ok(t.to)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TransitionRequest<K> {
pub id: K,
pub transition: String,
pub comment: Option<String>,
}
type SuccessFn<O> = Arc<dyn Fn(&O, &mut UiCtx) + Send + Sync>;
pub struct Transitions<S, K, A: Action> {
flow: Workflow<S>,
current: S,
id: K,
on_success: Option<SuccessFn<A::Output>>,
_action: PhantomData<fn() -> A>,
}
impl<S, K, A> Transitions<S, K, A>
where
S: WorkflowState,
K: Clone + Send + Sync + 'static,
A: Action<Input = TransitionRequest<K>>,
{
pub fn of(flow: Workflow<S>, current: S, id: K, _action: A) -> Self {
Transitions { flow, current, id, on_success: None, _action: PhantomData }
}
pub fn on_success(mut self, handler: impl Fn(&A::Output, &mut UiCtx) + Send + Sync + 'static) -> Self {
self.on_success = Some(Arc::new(handler));
self
}
}
impl<S, K, A> Component for Transitions<S, K, A>
where
S: WorkflowState,
K: Clone + Send + Sync + 'static,
A: Action<Input = TransitionRequest<K>>,
A::Output: Outcome,
{
fn render(&self, out: &mut String, r: &Render) {
let scope = r.scope;
let state = r.value(&self.current.to_value(), &S::KIND);
out.push_str(&format!(
r#"<div class="z-transitions" data-testid="{scope}.transitions"><p class="z-state">{}: <strong>{state}</strong></p>"#,
r.text(&Text::key("zeyn.workflow.state"))
));
let allowed = r.ctx.map(|c| self.flow.allowed(c.identity(), self.current)).unwrap_or_default();
if !allowed.is_empty() {
out.push_str(&format!(
r#"<form class="z-form" data-zeyn-submit="{action}" data-testid="{scope}.transitions.form" novalidate><input type="text" name="transition" hidden value=""><div class="z-field"><label for="{scope}-comment">{label}</label><textarea id="{scope}-comment" name="comment" maxlength="{MAX_COMMENT}" rows="2" aria-describedby="{scope}-comment-error"></textarea><p class="z-field-error" id="{scope}-comment-error" aria-live="polite"></p></div><div class="z-form-actions">"#,
action = A::META.name,
label = r.text(&Text::key("zeyn.workflow.comment")),
));
for t in allowed {
out.push_str(&format!(
r#"<button type="submit" class="z-button" data-zeyn-transition="{name}" data-testid="{scope}.transition.{name}">{}</button>"#,
r.text(&t.label),
name = escape(t.name),
));
}
out.push_str("</div></form>");
}
out.push_str("</div>");
}
fn schema(&self) -> Json {
json!({
"type": "transitions",
"action": A::META,
"state": state_text(self.current),
"transitions": self.flow.transitions.iter().map(|t| json!({
"name": t.name, "label": t.label, "from": state_text(t.from), "to": state_text(t.to), "permission": t.permission,
})).collect::<Vec<_>>(),
})
}
fn form(&self) -> Option<&dyn FormSource> {
Some(self)
}
}
impl<S, K, A> FormSource for Transitions<S, K, A>
where
S: WorkflowState,
K: Clone + Send + Sync + 'static,
A: Action<Input = TransitionRequest<K>>,
A::Output: Outcome,
{
fn action(&self) -> ActionMeta {
A::META
}
fn submit(&self, ctx: Ctx, values: Map<String, Json>) -> BoxFuture<Result<Vec<Effect>>> {
let text = |k: &str| values.get(k).and_then(Json::as_str).map(str::to_string);
let request = TransitionRequest {
id: self.id.clone(),
transition: text("transition").unwrap_or_default(),
comment: text("comment"),
};
let on_success = self.on_success.clone();
Box::pin(async move {
let output = A::call(ctx, request).await?;
if let Some(error) = output.failure() {
return Err(error);
}
let mut ui = UiCtx::default();
match on_success {
Some(handler) => handler(&output, &mut ui),
None => ui.toast(Text::key("zeyn.workflow.done")),
}
Ok(ui.into_effects())
})
}
fn tool_schema(&self) -> Json {
let names: Vec<&str> =
self.flow.transitions.iter().filter(|t| t.from == self.current).map(|t| t.name).collect();
json!({
"type": "object",
"properties": {
"transition": { "type": "string", "enum": names, "description": "Move to make." },
"comment": { "type": "string", "maxLength": MAX_COMMENT, "description": "Why, recorded in the audit log." }
},
"required": ["transition"]
})
}
}
impl<S, K, A> From<Transitions<S, K, A>> for Node
where
S: WorkflowState,
K: Clone + Send + Sync + 'static,
A: Action<Input = TransitionRequest<K>>,
A::Output: Outcome,
{
fn from(t: Transitions<S, K, A>) -> Self {
Node::new(t)
}
}