Trainer: followed_by - a group whose members each see their own cases; file fields
Test / test (pull_request) Successful in 1m14s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 17:36:08 +02:00
co-authored by Claude Opus 5.5
parent f2b131ebbb
commit a822f2175f
2 changed files with 64 additions and 5 deletions
+58 -5
View File
@@ -343,6 +343,14 @@ pub struct PlanNeed {
/// Groups that see these cases on their desk and move nothing.
#[serde(default)]
pub watched_by: Vec<String>,
/// Groups whose members each see *their own* cases and nobody
/// else's: the ones that carry the address they are signed in
/// with. A customer follows their assignment; a member sees what
/// they sent. The form must collect a required `email`, and the
/// group is usually one a stage `grants`, so the person who sent
/// the case is who ends up able to follow it.
#[serde(default)]
pub followed_by: Vec<String>,
/// The event name a stage is entered by, where it must not be the
/// builder's default (the stage's own name; `received` for the
/// first). A live site replays its records by event name, so a
@@ -432,7 +440,7 @@ impl Field {
/// What a public listing may show: everything but contact details
/// and what the plan marks private.
pub fn is_public(&self) -> bool {
!self.private && self.kind != "email" && self.kind != "tel"
!self.private && self.kind != "email" && self.kind != "tel" && self.kind != "file"
}
}
@@ -474,7 +482,7 @@ impl Plan {
}
}
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location"];
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file"];
fn is_id(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
@@ -687,6 +695,14 @@ impl Plan {
bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)"));
}
}
for group in &need.followed_by {
if !is_id(group) || group == PUBLIC || group == SUBMITTER {
bail!(at(&format!("followed_by must name groups, not {group:?}")));
}
if !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) {
bail!(at("followed_by shows a person the cases that carry their address, so the form must collect a required `email` of type email"));
}
}
for (stage, e) in &need.escalate {
if e.days == 0 {
bail!(at(&format!("escalate from {stage:?} must wait at least one day")));
@@ -1184,7 +1200,9 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
// The list itself says what a row is and what the
// buttons under it do; the card's own description says
// what the cases are.
let note = if moves.is_empty() {
let note = if follows_own(needs[0], &group) {
loc(lang, "Each row is one of your own cases and the stage it has reached.", "Hver rad er én av dine egne saker og hvor langt den har kommet.")
} else if moves.is_empty() {
loc(lang, "Each row is one case and the stage it has reached. Another desk moves these.", "Hver rad er én sak og hvor langt den har kommet. En annen pult flytter disse.")
} else {
loc(lang, "Each row is one case. The buttons record what you have just done with it.", "Hver rad er én sak. Knappene fører opp det du nettopp har gjort med den.")
@@ -1480,7 +1498,16 @@ fn escalated_states_for(need: &PlanNeed, group: &str) -> Vec<String> {
/// A group's desk shows a bucket when it moves something in it, is
/// answerable for it, or watches it.
fn on_desk(need: &PlanNeed, group: &str) -> bool {
group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty())
group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || need.followed_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty())
}
/// A group that has the bucket only to follow its own cases: its list
/// is narrowed to the signed-in person's address.
fn follows_own(need: &PlanNeed, group: &str) -> bool {
need.followed_by.iter().any(|g| g == group)
&& need.handled_by != group
&& !need.watched_by.iter().any(|g| g == group)
&& !need.moves_by.values().flatten().any(|m| m == group)
}
fn groups(plan: &Plan) -> BTreeSet<String> {
@@ -1493,6 +1520,7 @@ fn groups(plan: &Plan) -> BTreeSet<String> {
.chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned())
.chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned())
.chain(n.escalate.values().map(|e| e.to.clone()))
.chain(n.followed_by.iter().cloned())
})
.collect()
}
@@ -2154,6 +2182,10 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
if !escalated.is_empty() {
resource["states"] = json!(escalated);
}
// A group that follows its own cases sees those alone.
if follows_own(needs[0], &group) {
resource["where"] = json!({ "field": "email", "equals": "{user.email}" });
}
alternatives.push(json!({
"name": text(&bw["heading"]),
"description": text(&bw["description"]),
@@ -2570,7 +2602,7 @@ mod tests {
lands_in: "b".into(), handled_by: "g".into(),
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(),
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![],
collects: vec![], max_fields: 3,
}
@@ -3385,6 +3417,27 @@ mod invite_tests {
assert!(blank.validate().unwrap_err().to_string().contains("tones"));
}
#[test]
fn a_group_that_follows_sees_its_own_cases_and_a_form_may_take_a_file() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: job\n who: an owner\n wants: a report\n lands_in: jobs\n handled_by: office\n followed_by: [customers]\n stages: [open, accepted, delivered]\n done_when: [delivered]\n grants: { accepted: customers }\n max_fields: 3\n collects:\n - { name: name }\n - { name: email, type: email }\n - { name: drawings, type: file, optional: true }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let mine: Value = serde_yaml::from_str(&files["questions/customers/index.yaml"]).unwrap();
let resource = &mine["alternatives"][0]["features"][0]["resource"];
assert_eq!(resource["where"], json!({ "field": "email", "equals": "{user.email}" }));
assert_eq!(resource["transitions"], json!([]));
// The office sees everything.
let office: Value = serde_yaml::from_str(&files["questions/office/index.yaml"]).unwrap();
assert!(office["alternatives"][0]["features"][0]["resource"].get("where").is_none());
// The form takes the file.
assert!(files["questions/index.yaml"].contains("type: file"));
// A group the case grants does not invite into itself.
assert!(!files["questions/customers/index.yaml"].contains("invite:"));
let no_mail: Plan = serde_yaml::from_str(&yaml.replace(" - { name: email, type: email }\n", "").replace(" grants: { accepted: customers }\n", "")).unwrap();
assert!(no_mail.validate().unwrap_err().to_string().contains("followed_by"));
}
#[test]
fn a_document_is_a_page_with_one_card_per_section_word_for_word() {
let text = "# Vedtekter\n\n### av 23.04.2018\n\n##### § 1\n\nTomter vel er upolitisk. Foreningen har som\nformål å virke for stedets velferd.\n\n##### § 4.\n\nEkstraordinært årsmøte holdes når styret krever det.\n\nGjennomføringen følger samme prosedyre.\n";