plan: intake to plan by a model; doors, labelled groups, the whole card
Work from another session, committed as found (tests green): - `plan` reads an intake into a plan.yaml (a model decides needs, stages, forms and budgets; a person reads the result before build). - Plans gain `doors` (a first question before the forms, when a front door would carry more than five needs), `groups` with labels that a select may draw its options from (`options_from: groups`), `private` fields kept off public pages, `multiple` selects, and `max_fields` as what the intake said the person will tolerate. - `card.rs`: an alternative as the visitor meets it, in text, so the answering visitor and `rate` read the served card, not the heading. - `rate` reads followup pages from the repo and reports "what happens next" as not measured rather than zero when it cannot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
baf36319b7
commit
255aeb4f4b
+577
-76
@@ -36,6 +36,19 @@ pub struct Plan {
|
||||
/// model's wording, never structure.
|
||||
#[serde(default)]
|
||||
pub about: String,
|
||||
/// A first question before the forms. Each door is one alternative
|
||||
/// on the front door leading to a page of its own, where the needs
|
||||
/// that name it sit. Five or six needs on one page is more than a
|
||||
/// visitor sorts through; two or three doors of two or three each
|
||||
/// is what the page-load limit asks for.
|
||||
#[serde(default)]
|
||||
pub doors: Vec<Door>,
|
||||
/// Groups with a label the public may see: the committees a
|
||||
/// person can ask to join. A select field with `options_from:
|
||||
/// groups` offers exactly these, so the form and the desks can
|
||||
/// never disagree about what the committees are.
|
||||
#[serde(default)]
|
||||
pub groups: Vec<GroupDef>,
|
||||
pub needs: Vec<PlanNeed>,
|
||||
#[serde(default)]
|
||||
pub personas: Vec<Value>,
|
||||
@@ -45,6 +58,22 @@ fn english() -> String {
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GroupDef {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Door {
|
||||
pub id: String,
|
||||
/// Who comes through it, in plain words: context for the model's
|
||||
/// heading, and the fallback description.
|
||||
pub who: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Contact {
|
||||
pub name: String,
|
||||
@@ -59,6 +88,10 @@ pub struct PlanNeed {
|
||||
pub wants: String,
|
||||
#[serde(default)]
|
||||
pub enters_as: Option<String>,
|
||||
/// The door this public need sits behind; none means straight on
|
||||
/// the front door.
|
||||
#[serde(default)]
|
||||
pub door: Option<String>,
|
||||
pub lands_in: String,
|
||||
pub handled_by: String,
|
||||
/// Every stage, first to last, finished states included.
|
||||
@@ -82,8 +115,11 @@ pub struct PlanNeed {
|
||||
#[serde(default)]
|
||||
pub watched_by: Vec<String>,
|
||||
pub collects: Vec<Field>,
|
||||
#[serde(default)]
|
||||
pub max_fields: Option<usize>,
|
||||
/// How many required fields this person will tolerate: what the
|
||||
/// business said about them, not what the form happens to ask.
|
||||
/// The form is refused when it asks for more, and the verifier is
|
||||
/// told this number, so the check means something after handover.
|
||||
pub max_fields: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -96,8 +132,31 @@ pub struct Field {
|
||||
pub optional: bool,
|
||||
#[serde(default)]
|
||||
pub options: Vec<String>,
|
||||
/// `select` only: pick any number instead of exactly one.
|
||||
#[serde(default)]
|
||||
pub multiple: bool,
|
||||
/// `select` only: `groups` takes the options from the plan's
|
||||
/// labelled groups instead of a list here.
|
||||
#[serde(default)]
|
||||
pub options_from: Option<String>,
|
||||
/// Never shown on a public view of the bucket. `email` and `tel`
|
||||
/// fields are private whether or not this is set.
|
||||
#[serde(default)]
|
||||
pub private: bool,
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
/// The name a plan uses in `watched_by` for everyone: a read-only page
|
||||
/// of the bucket, no login.
|
||||
pub const PUBLIC: &str = "public";
|
||||
|
||||
fn text() -> String {
|
||||
"text".to_string()
|
||||
}
|
||||
@@ -110,6 +169,27 @@ fn is_id(s: &str) -> bool {
|
||||
|
||||
impl Plan {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
let mut doors = BTreeSet::new();
|
||||
for door in &self.doors {
|
||||
if !is_id(&door.id) || !doors.insert(&door.id) {
|
||||
bail!("door {:?}: id must be unique ASCII lowercase with - or _", door.id);
|
||||
}
|
||||
if !self.needs.iter().any(|n| n.door.as_ref() == Some(&door.id)) {
|
||||
bail!("door {:?} leads nowhere: no need names it", door.id);
|
||||
}
|
||||
if groups(self).contains(&door.id) {
|
||||
bail!("door {:?} is also a group; a door's page is public and a group's is gated", door.id);
|
||||
}
|
||||
if door.id == "status" || door.id == "received" || door.id == "decide" {
|
||||
bail!("door {:?}: that path is taken by the builder's own pages", door.id);
|
||||
}
|
||||
}
|
||||
let mut labelled = BTreeSet::new();
|
||||
for g in &self.groups {
|
||||
if !is_id(&g.id) || !labelled.insert(&g.id) || g.label.trim().is_empty() {
|
||||
bail!("group {:?}: id must be unique ASCII lowercase with - or _, and it needs a label", g.id);
|
||||
}
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for need in &self.needs {
|
||||
let at = |what: &str| format!("need {:?}: {what}", need.id);
|
||||
@@ -124,6 +204,14 @@ impl Plan {
|
||||
if need.stages.len() < 2 {
|
||||
bail!(at("stages needs a first stage and at least one finished one"));
|
||||
}
|
||||
if let Some(door) = &need.door {
|
||||
if !doors.contains(door) {
|
||||
bail!(at(&format!("door {door:?} is not one of the plan's doors")));
|
||||
}
|
||||
if need.enters_as.is_some() {
|
||||
bail!(at("a door is a public way in; a need with enters_as has its group's page instead"));
|
||||
}
|
||||
}
|
||||
for (state, movers) in &need.moves_by {
|
||||
if !need.stages[1..].contains(state) {
|
||||
bail!(at(&format!("moves_by names {state:?}, which is not a stage a case can move into")));
|
||||
@@ -138,7 +226,10 @@ impl Plan {
|
||||
}
|
||||
}
|
||||
if need.watched_by.iter().any(|g| !is_id(g)) {
|
||||
bail!(at("watched_by must list group identifiers"));
|
||||
bail!(at("watched_by must list group identifiers, or `public`"));
|
||||
}
|
||||
if need.watched_by.iter().any(|g| g == PUBLIC) && need.enters_as.is_some() {
|
||||
bail!(at("a public view is for what the public sent; an internal need has its group's desk"));
|
||||
}
|
||||
for state in need.done_when.iter().chain(&need.skippable) {
|
||||
if !need.stages.contains(state) {
|
||||
@@ -153,12 +244,35 @@ impl Plan {
|
||||
if need.done_when.is_empty() || need.done_when.contains(&need.stages[0]) {
|
||||
bail!(at("done_when must name finished states, and the first stage cannot be one"));
|
||||
}
|
||||
let required = need.collects.iter().filter(|f| !f.optional).count();
|
||||
if need.max_fields == 0 {
|
||||
bail!(at("max_fields is how many required fields this person will tolerate, and must be at least 1"));
|
||||
}
|
||||
if required > need.max_fields {
|
||||
bail!(at(&format!("the form asks for {required} required field(s) and this person will tolerate {}; mark some optional or drop them", need.max_fields)));
|
||||
}
|
||||
for field in &need.collects {
|
||||
if !is_id(&field.name) || !FIELD_TYPES.contains(&field.kind.as_str()) {
|
||||
bail!(at(&format!("field {:?}: name must be an identifier and type one of {FIELD_TYPES:?}", field.name)));
|
||||
}
|
||||
if field.kind == "select" && field.options.is_empty() {
|
||||
bail!(at(&format!("field {:?} is a select and lists no options", field.name)));
|
||||
match (field.kind.as_str(), field.options_from.as_deref()) {
|
||||
("select", Some("groups")) => {
|
||||
if self.groups.is_empty() {
|
||||
bail!(at(&format!("field {:?} takes its options from groups, and the plan has no labelled `groups`", field.name)));
|
||||
}
|
||||
if !field.options.is_empty() {
|
||||
bail!(at(&format!("field {:?} has both options and options_from", field.name)));
|
||||
}
|
||||
}
|
||||
("select", None) if field.options.is_empty() => {
|
||||
bail!(at(&format!("field {:?} is a select and lists no options", field.name)));
|
||||
}
|
||||
("select", None) => {}
|
||||
(_, Some(_)) => bail!(at(&format!("field {:?}: options_from is for select fields, and only `groups`", field.name))),
|
||||
(kind, None) if field.multiple && kind != "select" => {
|
||||
bail!(at(&format!("field {:?}: multiple is for select fields", field.name)))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +294,16 @@ impl Plan {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What sits on the front door: each door, and each public need
|
||||
/// with no door.
|
||||
fn front_door_count(&self) -> usize {
|
||||
self.doors.len() + self.needs.iter().filter(|n| n.enters_as.is_none() && n.door.is_none()).count()
|
||||
}
|
||||
|
||||
fn needs_behind(&self, door: &str) -> Vec<&PlanNeed> {
|
||||
self.needs.iter().filter(|n| n.door.as_deref() == Some(door)).collect()
|
||||
}
|
||||
|
||||
fn by_bucket(&self) -> BTreeMap<&str, Vec<&PlanNeed>> {
|
||||
let mut map: BTreeMap<&str, Vec<&PlanNeed>> = BTreeMap::new();
|
||||
for need in &self.needs {
|
||||
@@ -223,6 +347,21 @@ pub fn machine(need: &PlanNeed) -> BTreeMap<String, Vec<String>> {
|
||||
moves
|
||||
}
|
||||
|
||||
/// The stages a case goes through, as one sentence: "It is received,
|
||||
/// then surveyed, then quoted, and ends won or lost."
|
||||
fn stages_sentence(need: &PlanNeed) -> String {
|
||||
let line: Vec<String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| humanize(s).to_lowercase()).collect();
|
||||
let ends: Vec<String> = need.done_when.iter().map(|s| humanize(s).to_lowercase()).collect();
|
||||
let mut out = String::from("It is ");
|
||||
out.push_str(&line.join(", then "));
|
||||
if ends.len() == 1 {
|
||||
out.push_str(&format!(", and ends {}.", ends[0]));
|
||||
} else {
|
||||
out.push_str(&format!(", and ends {} or {}.", ends[..ends.len() - 1].join(", "), ends[ends.len() - 1]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn humanize(id: &str) -> String {
|
||||
let mut words = id.replace(['_', '-'], " ");
|
||||
if let Some(first) = words.get_mut(0..1) {
|
||||
@@ -243,41 +382,74 @@ fn internal_page(need: &PlanNeed, group: &str) -> String {
|
||||
/// made from the plan's own text. Keyed by file path, then the same
|
||||
/// JSON shape the model is asked for.
|
||||
pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
// Every part of the card portal renders has a slot: the model
|
||||
// gets to write the button, an encouragement, the section's icon
|
||||
// and guidance line, and a placeholder per field, not only the
|
||||
// heading and description. Empty fallbacks render as absent.
|
||||
let form = |need: &PlanNeed| {
|
||||
json!({
|
||||
"heading": need.wants,
|
||||
"description": format!("For {}.", need.who),
|
||||
"button": "Send",
|
||||
"encouragement": "",
|
||||
"icon": "",
|
||||
// Two sections of context the plan already knows: the road
|
||||
// a case travels, and who is at the other end. The
|
||||
// fallbacks are built from the plan, so they are true
|
||||
// before a model has said a word.
|
||||
"road_title": "What happens next",
|
||||
"road_description": stages_sentence(need),
|
||||
"road_icon": "route",
|
||||
"who_title": "Who reads it",
|
||||
"who_description": format!("{}. Questions go to {} at {}.", humanize(&need.handled_by), plan.contact.name, plan.contact.email),
|
||||
"who_icon": "users",
|
||||
"form_title": "Details",
|
||||
"form_description": "",
|
||||
"labels": need.collects.iter().map(|f| (f.name.clone(), Value::String(humanize(&f.name)))).collect::<Map<_, _>>(),
|
||||
"hints": need.collects.iter().map(|f| (f.name.clone(), Value::String(String::new()))).collect::<Map<_, _>>(),
|
||||
})
|
||||
};
|
||||
let mut words = BTreeMap::new();
|
||||
let public: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none()).collect();
|
||||
let direct: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none() && n.door.is_none()).collect();
|
||||
words.insert(
|
||||
"questions/index.yaml".to_string(),
|
||||
json!({
|
||||
"question": "What brings you here?",
|
||||
"description": plan.about,
|
||||
"needs": public.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
|
||||
"doors": plan.doors.iter().map(|d| (d.id.clone(), json!({
|
||||
"heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue",
|
||||
"icon": "", "for_whom": "",
|
||||
}))).collect::<Map<_, _>>(),
|
||||
"needs": direct.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
|
||||
}),
|
||||
);
|
||||
for door in &plan.doors {
|
||||
words.insert(
|
||||
door_path(door),
|
||||
json!({
|
||||
"question": "What do you need?",
|
||||
"description": format!("For {}.", door.who),
|
||||
"needs": plan.needs_behind(&door.id).iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for need in &plan.needs {
|
||||
let (path, back) = match &need.enters_as {
|
||||
None => (format!("questions/received/{}.yaml", need.id), "/".to_string()),
|
||||
Some(group) => (format!("questions/{group}/{}-sent.yaml", need.id), format!("/{group}")),
|
||||
};
|
||||
let _ = back;
|
||||
words.insert(
|
||||
path,
|
||||
json!({
|
||||
"question": "What happens now?",
|
||||
"description": format!("It has reached our {}.", humanize(&need.handled_by).to_lowercase()),
|
||||
"next_heading": "We read it and come back to you",
|
||||
"next_description": format!("Questions in the meantime go to {} at {}.", plan.contact.name, plan.contact.email),
|
||||
"back": "Back",
|
||||
}),
|
||||
);
|
||||
let mut page = json!({
|
||||
"question": "What happens now?",
|
||||
"description": format!("It has reached our {}.", humanize(&need.handled_by).to_lowercase()),
|
||||
"next_heading": "We read it and come back to you",
|
||||
"next_description": format!("Questions in the meantime go to {} at {}.", plan.contact.name, plan.contact.email),
|
||||
"back": "Back",
|
||||
});
|
||||
if need.watched_by.iter().any(|g| g == PUBLIC) {
|
||||
page["see_all"] = json!({ "heading": format!("See all {}", humanize(&need.lands_in).to_lowercase()), "description": "", "button": "Open" });
|
||||
}
|
||||
words.insert(path, page);
|
||||
if let Some(group) = &need.enters_as {
|
||||
let mut page = form(need);
|
||||
page["question"] = json!(format!("{}?", humanize(&need.id)));
|
||||
@@ -310,6 +482,20 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links }),
|
||||
);
|
||||
}
|
||||
// A public view per bucket the plan opens: a read-only list.
|
||||
for (bucket, needs) in public_buckets(plan) {
|
||||
words.insert(
|
||||
public_view_path(bucket),
|
||||
json!({
|
||||
"question": format!("What has been sent in about {}?", humanize(bucket).to_lowercase()),
|
||||
"description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
|
||||
"heading": humanize(bucket),
|
||||
"list_description": "",
|
||||
"back": "Back",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The submitter's own page: reached by their private link, it
|
||||
// offers the moves that are theirs to make.
|
||||
for need in &plan.needs {
|
||||
@@ -332,12 +518,19 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
words
|
||||
}
|
||||
|
||||
fn door_path(door: &Door) -> String {
|
||||
format!("questions/{}/index.yaml", door.id)
|
||||
}
|
||||
|
||||
fn decide_path(need: &PlanNeed) -> String {
|
||||
format!("questions/decide/{}.yaml", need.id)
|
||||
}
|
||||
|
||||
const SUBMITTER: &str = "submitter";
|
||||
|
||||
/// Alternatives on one page before a visitor stops reading them.
|
||||
const MAX_FRONT_DOOR: usize = 5;
|
||||
|
||||
/// Who presses the button into `to`.
|
||||
fn movers<'a>(need: &'a PlanNeed, to: &str) -> Vec<&'a str> {
|
||||
match need.moves_by.get(to) {
|
||||
@@ -358,7 +551,7 @@ fn moves_of(need: &PlanNeed, who: &str) -> Vec<(String, 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 {
|
||||
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) || !moves_of(need, group).is_empty())
|
||||
}
|
||||
|
||||
fn groups(plan: &Plan) -> BTreeSet<String> {
|
||||
@@ -368,12 +561,29 @@ fn groups(plan: &Plan) -> BTreeSet<String> {
|
||||
[Some(n.handled_by.clone()), n.enters_as.clone()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(n.watched_by.iter().cloned())
|
||||
.chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned())
|
||||
.chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Buckets the public may read, with the needs that fill them.
|
||||
fn public_buckets(plan: &Plan) -> Vec<(&str, Vec<&PlanNeed>)> {
|
||||
plan.by_bucket().into_iter().filter(|(_, needs)| needs.iter().any(|n| n.watched_by.iter().any(|g| g == PUBLIC))).collect()
|
||||
}
|
||||
|
||||
fn public_view_path(bucket: &str) -> String {
|
||||
format!("questions/status/{bucket}/index.yaml")
|
||||
}
|
||||
|
||||
/// The options of a select: its own list, or the plan's labelled groups.
|
||||
fn select_options<'a>(plan: &'a Plan, field: &'a Field) -> Vec<&'a str> {
|
||||
match field.options_from.as_deref() {
|
||||
Some("groups") => plan.groups.iter().map(|g| g.label.as_str()).collect(),
|
||||
_ => field.options.iter().map(String::as_str).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the model's strings where it gave them, key by key, and keeps
|
||||
/// the fallback everywhere else. The shape always comes from the
|
||||
/// fallback, so a reply can never add, drop or restructure anything.
|
||||
@@ -398,45 +608,107 @@ const SLOT: &str = "<WRITE";
|
||||
/// copy straight back (measured: nemotron returned the front door
|
||||
/// unchanged). A slot cannot be mistaken for an answer.
|
||||
fn slots(value: &Value, key: &str) -> Value {
|
||||
slots_in(value, key, "")
|
||||
}
|
||||
|
||||
fn slots_in(value: &Value, key: &str, parent: &str) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), slots(v, k))).collect()),
|
||||
Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), slots_in(v, k, key))).collect()),
|
||||
Value::String(_) => Value::String(format!(
|
||||
"{SLOT}: {}>",
|
||||
match key {
|
||||
"question" => "the one question this page asks its reader".to_string(),
|
||||
"heading" | "next_heading" => "a heading, at most eight words, saying what the reader gets".to_string(),
|
||||
"description" | "page_description" | "next_description" => "one or two short sentences".to_string(),
|
||||
"button" | "back" | "confirm" => "a button, one to three words".to_string(),
|
||||
"form_title" => "a title for the form, two to four words".to_string(),
|
||||
other if other.contains("->") => format!("a button, two to four words, for a case going {other}"),
|
||||
other => format!("a form label for the field `{other}`"),
|
||||
match (parent, key) {
|
||||
(_, "question") => "the one question this page asks its reader".to_string(),
|
||||
(_, "heading" | "next_heading") => "a heading, at most eight words, saying what the reader gets".to_string(),
|
||||
(_, "description" | "page_description" | "next_description") => "one or two short sentences".to_string(),
|
||||
(_, "button" | "back" | "confirm") => "a button, one to three words, saying what pressing it does".to_string(),
|
||||
(_, "encouragement") => "one reassuring sentence: what happens with what they send, or how little it takes; or empty".to_string(),
|
||||
(_, "icon") => "an icon name from the list at the end, the one that fits this best; or empty".to_string(),
|
||||
(_, "for_whom") => "a few words naming who this is for, addressed to them; or empty".to_string(),
|
||||
(_, "form_title") => "a title for the form, two to four words".to_string(),
|
||||
(_, "form_description") => "one short line of guidance above the form, or empty".to_string(),
|
||||
(_, "road_title") => "a short title, two to four words, for the line that says what happens after sending".to_string(),
|
||||
(_, "road_description") => "one or two sentences walking the reader through the stages this goes through, in order (the stages are listed above), in their words".to_string(),
|
||||
(_, "road_icon" | "who_icon") => "an icon name from the list at the end that fits this section; or empty".to_string(),
|
||||
(_, "who_title") => "a short title, two to four words, for the line that says who reads it".to_string(),
|
||||
(_, "list_description") => "one line over a public list of what people have sent in, or empty".to_string(),
|
||||
(_, "who_description") => "one sentence: who reads what they send, and how to reach them meanwhile".to_string(),
|
||||
("hints", field) => format!("an example answer for the field `{field}`, as a placeholder; or empty"),
|
||||
(_, other) if other.contains("->") => format!("a button, two to four words, for a case going {other}"),
|
||||
(_, other) => format!("a form label for the field `{other}`"),
|
||||
}
|
||||
)),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Icons the words may name: a fixed set from Iconify's lucide pack,
|
||||
/// so a reply can only pick one that exists. Anything else is dropped.
|
||||
pub const ICONS: &[&str] = &[
|
||||
"house", "building-2", "store", "school", "map-pin", "route", "footprints", "trees", "leaf", "snowflake", "sun",
|
||||
"users", "user", "user-plus", "user-check", "baby", "heart-handshake", "hand-helping", "handshake", "smile",
|
||||
"mail", "phone", "message-circle", "megaphone", "bell", "send", "inbox",
|
||||
"alert-triangle", "siren", "shield", "construction", "hammer", "wrench", "ruler", "package", "truck", "car", "bus", "train-front", "bike",
|
||||
"calendar", "clock", "file-text", "clipboard-list", "receipt", "wallet", "coins", "banknote", "credit-card",
|
||||
"key", "lock", "search", "star", "gift", "shopping-cart", "lightbulb", "flag", "thumbs-up", "check-circle",
|
||||
"briefcase", "stethoscope", "activity", "camera", "pencil", "refresh-cw", "list", "layers", "vote", "scale",
|
||||
];
|
||||
|
||||
/// `lucide:<name>` for a name on the list, otherwise nothing.
|
||||
pub fn icon_name(given: &str) -> Option<String> {
|
||||
let name = given.trim().trim_start_matches("lucide:");
|
||||
ICONS.contains(&name).then(|| format!("lucide:{name}"))
|
||||
}
|
||||
|
||||
fn s(v: &Value) -> &str {
|
||||
v.as_str().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn form_alternative(need: &PlanNeed, w: &Value, action: &str) -> Value {
|
||||
json!({
|
||||
fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Value {
|
||||
let mut feature = json!({
|
||||
"name": s(&w["form_title"]),
|
||||
"requirements": need.collects.iter().map(|f| {
|
||||
let mut r = json!({ "name": f.name, "label": s(&w["labels"][&f.name]), "type": f.kind });
|
||||
if f.optional { r["optional"] = json!(true); }
|
||||
if f.kind == "select" { r["options"] = json!(select_options(plan, f)); }
|
||||
if f.multiple { r["multiple"] = json!(true); }
|
||||
let hint = s(&w["hints"][&f.name]).trim();
|
||||
if !hint.is_empty() && f.kind != "select" { r["placeholder"] = json!(hint); }
|
||||
r
|
||||
}).collect::<Vec<_>>(),
|
||||
});
|
||||
if !s(&w["form_description"]).trim().is_empty() {
|
||||
feature["description"] = json!(s(&w["form_description"]).trim());
|
||||
}
|
||||
if let Some(icon) = icon_name(s(&w["icon"])) {
|
||||
feature["icon"] = json!(icon);
|
||||
}
|
||||
// Context first, then the form: the reader learns where this goes
|
||||
// and who is at the other end before being asked for anything.
|
||||
let strip = |title: &str, text: &str, icon: &str| {
|
||||
let mut f = json!({ "name": title.trim(), "description": text.trim() });
|
||||
if let Some(icon) = icon_name(icon) { f["icon"] = json!(icon); }
|
||||
f
|
||||
};
|
||||
let mut features = Vec::new();
|
||||
if !s(&w["road_description"]).trim().is_empty() {
|
||||
features.push(strip(s(&w["road_title"]), s(&w["road_description"]), s(&w["road_icon"])));
|
||||
}
|
||||
if !s(&w["who_description"]).trim().is_empty() {
|
||||
features.push(strip(s(&w["who_title"]), s(&w["who_description"]), s(&w["who_icon"])));
|
||||
}
|
||||
features.push(feature);
|
||||
let mut alt = json!({
|
||||
"name": s(&w["heading"]),
|
||||
"description": s(&w["description"]),
|
||||
"action": action,
|
||||
"consequence": [s(&w["button"])],
|
||||
"record_as": need.lands_in,
|
||||
"features": [{
|
||||
"name": s(&w["form_title"]),
|
||||
"requirements": need.collects.iter().map(|f| {
|
||||
let mut r = json!({ "name": f.name, "label": s(&w["labels"][&f.name]), "type": f.kind });
|
||||
if f.optional { r["optional"] = json!(true); }
|
||||
if !f.options.is_empty() { r["options"] = json!(f.options); }
|
||||
r
|
||||
}).collect::<Vec<_>>(),
|
||||
}],
|
||||
})
|
||||
"features": features,
|
||||
});
|
||||
if !s(&w["encouragement"]).trim().is_empty() {
|
||||
alt["encouragements"] = json!([s(&w["encouragement"]).trim()]);
|
||||
}
|
||||
alt
|
||||
}
|
||||
|
||||
/// Every file of the content repo, from the plan and a set of words.
|
||||
@@ -461,37 +733,83 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
}).collect::<Vec<_>>() }),
|
||||
);
|
||||
|
||||
// The front door: one alternative per need the public may bring.
|
||||
// The front door: one alternative per door, then one per need the
|
||||
// public brings straight to it.
|
||||
let front = &words["questions/index.yaml"];
|
||||
let public: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none()).collect();
|
||||
let mut alternatives: Vec<Value> = plan
|
||||
.doors
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let dw = &front["doors"][&d.id];
|
||||
let mut alt = json!({
|
||||
"name": s(&dw["heading"]),
|
||||
"description": s(&dw["description"]),
|
||||
"action": format!("/{}", d.id),
|
||||
"consequence": [s(&dw["button"])],
|
||||
});
|
||||
// A door carries no form; its icon and "for whom" line
|
||||
// ride an empty section, which portal renders as a
|
||||
// labelled strip on the card.
|
||||
let (icon, for_whom) = (icon_name(s(&dw["icon"])), s(&dw["for_whom"]).trim());
|
||||
if icon.is_some() || !for_whom.is_empty() {
|
||||
let mut f = json!({ "name": for_whom, "description": "" });
|
||||
if let Some(icon) = icon { f["icon"] = json!(icon); }
|
||||
alt["features"] = json!([f]);
|
||||
}
|
||||
alt
|
||||
})
|
||||
.collect();
|
||||
alternatives.extend(
|
||||
plan.needs
|
||||
.iter()
|
||||
.filter(|n| n.enters_as.is_none() && n.door.is_none())
|
||||
.map(|n| form_alternative(plan, n, &front["needs"][&n.id], &public_followup(n))),
|
||||
);
|
||||
if plan.front_door_count() > MAX_FRONT_DOOR {
|
||||
eprintln!(
|
||||
"build: the front door offers {} alternatives; past {MAX_FRONT_DOOR} a visitor stops reading. Put needs behind doors.",
|
||||
plan.front_door_count()
|
||||
);
|
||||
}
|
||||
files.insert(
|
||||
"questions/index.yaml".into(),
|
||||
json!({
|
||||
"name": s(&front["question"]),
|
||||
"description": s(&front["description"]),
|
||||
"responsible": responsible,
|
||||
"alternatives": public.iter().map(|n| form_alternative(n, &front["needs"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
|
||||
}),
|
||||
json!({ "name": s(&front["question"]), "description": s(&front["description"]), "responsible": responsible, "alternatives": alternatives }),
|
||||
);
|
||||
|
||||
// A door's page: a nav page (index.yaml), public, holding the
|
||||
// forms of the needs behind it.
|
||||
for door in &plan.doors {
|
||||
let path = door_path(door);
|
||||
let w = &words[&path];
|
||||
files.insert(
|
||||
path,
|
||||
json!({
|
||||
"name": s(&w["question"]),
|
||||
"description": s(&w["description"]),
|
||||
"responsible": responsible,
|
||||
"alternatives": plan.needs_behind(&door.id).iter().map(|n| form_alternative(plan, n, &w["needs"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for need in &plan.needs {
|
||||
let (path, back) = match &need.enters_as {
|
||||
None => (format!("questions/received/{}.yaml", need.id), "/".to_string()),
|
||||
Some(group) => (format!("questions/{group}/{}-sent.yaml", need.id), format!("/{group}")),
|
||||
};
|
||||
let w = &words[&path];
|
||||
let mut alternatives = vec![
|
||||
json!({ "name": s(&w["next_heading"]), "description": s(&w["next_description"]) }),
|
||||
];
|
||||
if need.watched_by.iter().any(|g| g == PUBLIC) {
|
||||
alternatives.push(json!({
|
||||
"name": s(&w["see_all"]["heading"]), "description": s(&w["see_all"]["description"]),
|
||||
"action": format!("/status/{}", need.lands_in), "consequence": [s(&w["see_all"]["button"])],
|
||||
}));
|
||||
}
|
||||
alternatives.push(json!({ "name": s(&w["back"]), "description": "", "action": back, "consequence": [s(&w["back"])] }));
|
||||
// Nested and not an index: portal infers a followup page.
|
||||
files.insert(
|
||||
path,
|
||||
json!({
|
||||
"name": s(&w["question"]),
|
||||
"description": s(&w["description"]),
|
||||
"alternatives": [
|
||||
{ "name": s(&w["next_heading"]), "description": s(&w["next_description"]) },
|
||||
{ "name": s(&w["back"]), "description": "", "action": back, "consequence": [s(&w["back"])] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
files.insert(path, json!({ "name": s(&w["question"]), "description": s(&w["description"]), "alternatives": alternatives }));
|
||||
if let Some(group) = &need.enters_as {
|
||||
let path = format!("questions/{group}/{}.yaml", need.id);
|
||||
let w = &words[&path];
|
||||
@@ -503,7 +821,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
"name": s(&w["question"]),
|
||||
"description": s(&w["page_description"]),
|
||||
"responsible": responsible,
|
||||
"alternatives": [form_alternative(need, w, &format!("/{group}/{}-sent", need.id))],
|
||||
"alternatives": [form_alternative(plan, need, w, &format!("/{group}/{}-sent", need.id))],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -550,6 +868,47 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
);
|
||||
}
|
||||
|
||||
// Public views: an ungated nav page per opened bucket, listing it
|
||||
// read-only, with contact details and private fields projected
|
||||
// away. Anyone can see what was sent in and where it stands.
|
||||
for (bucket, needs) in public_buckets(plan) {
|
||||
let path = public_view_path(bucket);
|
||||
let w = &words[&path];
|
||||
let mut shown: BTreeSet<&str> = BTreeSet::new();
|
||||
for n in &needs {
|
||||
shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str()));
|
||||
}
|
||||
// Records keep their shape (state, timestamps) so portal
|
||||
// renders them as records; only the answers are pruned. One
|
||||
// record per yielded value: portal collects what the filter
|
||||
// yields into the list, so `map` would nest it once more
|
||||
// (measured: an empty bucket rendered as `[[]]`).
|
||||
let projection = format!(
|
||||
".[] | .responses |= {{{}}}",
|
||||
shown.iter().map(|f| format!("{f}: .{f}")).collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
files.insert(
|
||||
path,
|
||||
json!({
|
||||
"name": s(&w["question"]),
|
||||
"description": s(&w["description"]),
|
||||
"responsible": responsible,
|
||||
"alternatives": [
|
||||
{
|
||||
"name": s(&w["heading"]),
|
||||
"description": s(&w["list_description"]),
|
||||
"features": [{ "name": "", "description": "", "resource": {
|
||||
"source": { "kind": "kv", "bucket": bucket },
|
||||
"public": true,
|
||||
"jq": projection,
|
||||
}}],
|
||||
},
|
||||
{ "name": s(&w["back"]), "description": "", "action": "/", "consequence": [s(&w["back"])] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// A submitter's own moves: a followup page (nested, not an index)
|
||||
// whose alternatives are self_transitions, authorized at runtime by
|
||||
// the record's link plus the email they gave.
|
||||
@@ -581,16 +940,18 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
"needs.yaml".into(),
|
||||
json!({
|
||||
"needs": plan.needs.iter().map(|n| {
|
||||
let (enters, heading) = match &n.enters_as {
|
||||
None => ("/".to_string(), s(&front["needs"][&n.id]["heading"]).to_string()),
|
||||
Some(group) => (internal_page(n, group), s(&words[&format!("questions/{group}/{}.yaml", n.id)]["heading"]).to_string()),
|
||||
// `via` pins the alternative on the entry page: the
|
||||
// door's heading for a need behind one, else the form's.
|
||||
let (enters, heading, steps) = match (&n.enters_as, &n.door) {
|
||||
(Some(group), _) => (internal_page(n, group), s(&words[&format!("questions/{group}/{}.yaml", n.id)]["heading"]).to_string(), 1),
|
||||
(None, Some(door)) => ("/".to_string(), s(&front["doors"][door]["heading"]).to_string(), 2),
|
||||
(None, None) => ("/".to_string(), s(&front["needs"][&n.id]["heading"]).to_string(), 1),
|
||||
};
|
||||
let required = n.collects.iter().filter(|f| !f.optional).count();
|
||||
let mut need = json!({
|
||||
"id": n.id, "who": n.who, "wants": n.wants, "enters": enters, "via": heading,
|
||||
"lands_in": n.lands_in, "handled_by": n.handled_by,
|
||||
"stages": n.stages, "done_when": n.done_when, "success": n.success,
|
||||
"max_steps": 1, "max_fields": n.max_fields.unwrap_or(required),
|
||||
"max_steps": steps, "max_fields": n.max_fields,
|
||||
});
|
||||
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
|
||||
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
|
||||
@@ -608,7 +969,11 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
|
||||
let purpose = if path.contains("/decide/") {
|
||||
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
|
||||
} else if path == "questions/index.yaml" {
|
||||
"the front door: the first page any visitor sees. `question` is the page's one question; under `needs`, each entry is one alternative the visitor can pick, with the form behind it."
|
||||
"the front door: the first page any visitor sees. `question` is the page's one question. Under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor: its heading must let that visitor recognise themselves from the heading alone. Under `needs`, each entry is one alternative with a form behind it."
|
||||
} else if path.starts_with("questions/status/") {
|
||||
"a public page anyone can open, listing everything sent in to one bucket and where each one stands, read-only. `question` is what this page asks; `heading` names the list."
|
||||
} else if plan.doors.iter().any(|d| door_path(d) == path) {
|
||||
"a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it."
|
||||
} else if path.ends_with("/index.yaml") {
|
||||
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open."
|
||||
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
|
||||
@@ -616,19 +981,25 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
|
||||
} else {
|
||||
"a page only one role inside the business can open, holding one form."
|
||||
};
|
||||
let context: Vec<String> = plan
|
||||
.needs
|
||||
let mut context: Vec<String> = plan
|
||||
.doors
|
||||
.iter()
|
||||
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> ")))
|
||||
.map(|d| format!("- door {}: for {}; behind it: {}", d.id, d.who, plan.needs_behind(&d.id).iter().map(|n| n.id.as_str()).collect::<Vec<_>>().join(", ")))
|
||||
.collect();
|
||||
context.extend(
|
||||
plan.needs
|
||||
.iter()
|
||||
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "))),
|
||||
);
|
||||
format!(
|
||||
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- Write the way people in this trade and country actually speak.\n\n{}\n\nReply with the JSON object only.",
|
||||
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.\n\n{}\n\nReply with the JSON object only.",
|
||||
plan.business,
|
||||
plan.about.trim(),
|
||||
plan.contact.name,
|
||||
plan.contact.email,
|
||||
plan.language,
|
||||
context.join("\n"),
|
||||
ICONS.join(", "),
|
||||
serde_json::to_string_pretty(&slots(fallback, "")).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
@@ -692,11 +1063,11 @@ mod tests {
|
||||
fn need(stages: &[&str], done: &[&str], success: &[&str], skippable: &[&str]) -> PlanNeed {
|
||||
let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::<Vec<_>>();
|
||||
PlanNeed {
|
||||
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None,
|
||||
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, door: None,
|
||||
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![],
|
||||
collects: vec![], max_fields: None,
|
||||
collects: vec![], max_fields: 3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +1094,42 @@ mod tests {
|
||||
assert_eq!(m["in_hand"], ["closed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_card_carries_button_encouragement_icon_guidance_and_placeholders() {
|
||||
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
|
||||
let mut words = fallback_words(&plan);
|
||||
let front = words.get_mut("questions/index.yaml").unwrap();
|
||||
front["needs"]["survey"]["button"] = json!("Book a look");
|
||||
front["needs"]["survey"]["encouragement"] = json!("We call within two days.");
|
||||
front["needs"]["survey"]["icon"] = json!("house");
|
||||
front["needs"]["survey"]["form_description"] = json!("The address is enough to start.");
|
||||
front["needs"]["survey"]["hints"]["email"] = json!("you@example.no");
|
||||
let files = render(&plan, &words).unwrap();
|
||||
let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
|
||||
let alt = &page["alternatives"][0];
|
||||
assert_eq!(alt["consequence"][0], "Book a look");
|
||||
assert_eq!(alt["encouragements"][0], "We call within two days.");
|
||||
// Two context sections from the plan come before the form.
|
||||
let f = alt["features"].as_array().unwrap();
|
||||
assert_eq!(f.len(), 3);
|
||||
assert_eq!(f[0]["name"], "What happens next");
|
||||
assert_eq!(f[0]["description"], "It is open, then surveyed, then quoted, and ends won or lost.");
|
||||
assert_eq!(f[0]["icon"], "lucide:route");
|
||||
assert_eq!(f[1]["name"], "Who reads it");
|
||||
assert_eq!(f[1]["description"], "Project managers. Questions go to Ingrid at i@n.example.");
|
||||
assert_eq!(f[2]["icon"], "lucide:house");
|
||||
assert_eq!(f[2]["description"], "The address is enough to start.");
|
||||
assert_eq!(f[2]["requirements"][0]["placeholder"], "you@example.no");
|
||||
// An icon off the list is dropped, not passed through.
|
||||
assert_eq!(icon_name("skull"), None);
|
||||
assert_eq!(icon_name("lucide:house").as_deref(), Some("lucide:house"));
|
||||
// Empty slots leave nothing behind: no encouragement key, no form icon.
|
||||
let plain = render(&plan, &fallback_words(&plan)).unwrap();
|
||||
let page: Value = serde_yaml::from_str(&plain["questions/index.yaml"]).unwrap();
|
||||
assert!(page["alternatives"][0].get("encouragements").is_none());
|
||||
assert!(page["alternatives"][0]["features"][2].get("icon").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_take_the_models_strings_and_never_its_structure() {
|
||||
let fallback = json!({"question": "What?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}});
|
||||
@@ -731,7 +1138,7 @@ mod tests {
|
||||
assert_eq!(merged, json!({"question": "Hva trenger du?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}}));
|
||||
}
|
||||
|
||||
const PLAN: &str = "business: Nordvik Bygg\ncontact: { name: Ingrid, email: i@n.example }\nabout: We fix old buildings.\nneeds:\n - id: survey\n who: owners\n wants: Someone to look at the building\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, surveyed, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n collects:\n - { name: email, type: email }\n - { name: problem, type: textarea }\n - { name: address, optional: true }\n - id: report\n who: crew\n wants: Report a hazard\n enters_as: crew\n lands_in: site_issues\n handled_by: hse\n stages: [open, in_hand, closed]\n done_when: [closed]\n success: [closed]\n collects:\n - { name: kind, type: select, options: [Hazard, Near miss] }\n";
|
||||
const PLAN: &str = "business: Nordvik Bygg\ncontact: { name: Ingrid, email: i@n.example }\nabout: We fix old buildings.\nneeds:\n - id: survey\n who: owners\n wants: Someone to look at the building\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, surveyed, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n max_fields: 4\n collects:\n - { name: email, type: email }\n - { name: problem, type: textarea }\n - { name: address, optional: true }\n - id: report\n who: crew\n wants: Report a hazard\n enters_as: crew\n lands_in: site_issues\n handled_by: hse\n stages: [open, in_hand, closed]\n done_when: [closed]\n success: [closed]\n max_fields: 3\n collects:\n - { name: kind, type: select, options: [Hazard, Near miss] }\n";
|
||||
|
||||
#[test]
|
||||
fn a_plan_renders_every_file_the_site_needs() {
|
||||
@@ -754,13 +1161,13 @@ mod tests {
|
||||
assert!(files["questions/hse/index.yaml"].contains("to: closed"));
|
||||
// The spec names the heading the words gave, and the budget the form implies.
|
||||
assert!(files["needs.yaml"].contains("via: Someone to look at the building"));
|
||||
assert!(files["needs.yaml"].contains("max_fields: 2"));
|
||||
assert!(files["needs.yaml"].contains("max_fields: 4"));
|
||||
assert!(files["needs.yaml"].contains("enters: /crew/report"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_overlap_on_one_aggregate_each_with_their_own_buttons() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: change\n who: a client\n wants: a change priced and done\n lands_in: change_orders\n handled_by: project_managers\n stages: [open, priced, approved, completed, declined]\n done_when: [completed, declined]\n success: [completed]\n moves_by:\n approved: [submitter]\n declined: [submitter, project_managers]\n completed: [site_managers]\n watched_by: [accounts]\n collects:\n - { name: email, type: email }\n - { name: change, type: textarea }\n";
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: change\n who: a client\n wants: a change priced and done\n lands_in: change_orders\n handled_by: project_managers\n stages: [open, priced, approved, completed, declined]\n done_when: [completed, declined]\n success: [completed]\n moves_by:\n approved: [submitter]\n declined: [submitter, project_managers]\n completed: [site_managers]\n watched_by: [accounts]\n max_fields: 3\n collects:\n - { name: email, type: email }\n - { name: change, type: textarea }\n";
|
||||
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
|
||||
plan.validate().unwrap();
|
||||
let files = render(&plan, &fallback_words(&plan)).unwrap();
|
||||
@@ -787,11 +1194,105 @@ mod tests {
|
||||
assert!(plan.validate().unwrap_err().to_string().contains("required `email`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_door_is_a_first_question_with_its_needs_on_a_page_of_their_own() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoors:\n - { id: your-building, who: an owner with a building }\nneeds:\n - id: survey\n who: owners\n wants: a look\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: build\n who: owners\n wants: a price\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: apply\n who: workers\n wants: a job\n lands_in: applicants\n handled_by: pms\n stages: [open, hired]\n done_when: [hired]\n max_fields: 1\n collects:\n - { name: name }\n";
|
||||
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
|
||||
plan.validate().unwrap();
|
||||
let files = render(&plan, &fallback_words(&plan)).unwrap();
|
||||
// The front door: the door, then the direct need.
|
||||
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
|
||||
let names: Vec<&str> = front["alternatives"].as_array().unwrap().iter().map(|a| a["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Your building", "a job"]);
|
||||
assert_eq!(front["alternatives"][0]["action"], "/your-building");
|
||||
assert!(front["alternatives"][0].get("record_as").is_none());
|
||||
// The door's page holds both forms and is not gated.
|
||||
let door: Value = serde_yaml::from_str(&files["questions/your-building/index.yaml"]).unwrap();
|
||||
assert_eq!(door["alternatives"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(door["alternatives"][0]["record_as"], "enquiries");
|
||||
assert!(!files.contains_key("questions/your-building/_section.yaml"));
|
||||
// The spec pins the door as the way in, two submissions deep.
|
||||
let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap();
|
||||
assert_eq!(needs["needs"][0]["via"], "Your building");
|
||||
assert_eq!(needs["needs"][0]["max_steps"], 2);
|
||||
assert_eq!(needs["needs"][2]["max_steps"], 1);
|
||||
assert_eq!(plan.front_door_count(), 2);
|
||||
|
||||
// A door nobody is behind, a door on an internal need, and a
|
||||
// door that is also a group are refused.
|
||||
let lonely: Plan = serde_yaml::from_str(&yaml.replace(" door: your-building\n", "")).unwrap();
|
||||
assert!(lonely.validate().unwrap_err().to_string().contains("leads nowhere"));
|
||||
let internal: Plan = serde_yaml::from_str(&yaml.replacen(" door: your-building\n", " door: your-building\n enters_as: pms\n", 1)).unwrap();
|
||||
assert!(internal.validate().unwrap_err().to_string().contains("public way in"));
|
||||
let clash: Plan = serde_yaml::from_str(&yaml.replace("your-building", "pms")).unwrap();
|
||||
assert!(clash.validate().unwrap_err().to_string().contains("also a group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_can_take_many_and_draw_their_options_from_the_labelled_groups() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ngroups:\n - { id: trafikk, label: Trafikkforhold }\n - { id: elvesti, label: Elvesti }\nneeds:\n - id: komite\n who: en som vil sitte i en komité\n wants: en plass\n lands_in: soknader\n handled_by: styret\n stages: [open, plassert, avslatt]\n done_when: [plassert, avslatt]\n max_fields: 3\n collects:\n - { name: navn }\n - { name: komite, type: select, options_from: groups, multiple: true }\n";
|
||||
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
|
||||
plan.validate().unwrap();
|
||||
let files = render(&plan, &fallback_words(&plan)).unwrap();
|
||||
let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
|
||||
let field = &page["alternatives"][0]["features"][2]["requirements"][1];
|
||||
assert_eq!(field["options"], json!(["Trafikkforhold", "Elvesti"]));
|
||||
assert_eq!(field["multiple"], true);
|
||||
// Without labelled groups there is nothing to draw from.
|
||||
let no_groups: Plan = serde_yaml::from_str(&yaml.replace("groups:\n - { id: trafikk, label: Trafikkforhold }\n - { id: elvesti, label: Elvesti }\n", "")).unwrap();
|
||||
assert!(no_groups.validate().unwrap_err().to_string().contains("no labelled"));
|
||||
// `multiple` on a text field is a mistake, not a no-op.
|
||||
let bad: Plan = serde_yaml::from_str(&yaml.replace("{ name: navn }", "{ name: navn, multiple: true }")).unwrap();
|
||||
assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_public_watcher_gets_a_read_only_page_without_contact_details() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: trafikk\n who: en som har sett noe farlig\n wants: at det tas opp\n lands_in: meldinger\n handled_by: trafikk\n stages: [open, ordnet]\n done_when: [ordnet]\n watched_by: [styret, public]\n max_fields: 2\n collects:\n - { name: hvor }\n - { name: hva, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: navn, optional: true, private: true }\n";
|
||||
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
|
||||
plan.validate().unwrap();
|
||||
let files = render(&plan, &fallback_words(&plan)).unwrap();
|
||||
// `public` is not a group: no desk directory for it.
|
||||
assert!(!files.contains_key("questions/public/index.yaml"));
|
||||
assert!(files.contains_key("questions/styret/index.yaml"));
|
||||
let view: Value = serde_yaml::from_str(&files["questions/status/meldinger/index.yaml"]).unwrap();
|
||||
let resource = &view["alternatives"][0]["features"][0]["resource"];
|
||||
assert_eq!(resource["public"], true);
|
||||
assert!(resource.get("requires_group").is_none());
|
||||
assert_eq!(resource["jq"], ".[] | .responses |= {hva: .hva, hvor: .hvor}");
|
||||
assert!(view.get("qualifies").is_none());
|
||||
// The followup page points people at the list.
|
||||
let followup: Value = serde_yaml::from_str(&files["questions/received/trafikk.yaml"]).unwrap();
|
||||
assert_eq!(followup["alternatives"][1]["action"], "/status/meldinger");
|
||||
// An internal need cannot be public.
|
||||
let internal: Plan = serde_yaml::from_str(&yaml.replace(" lands_in:", " enters_as: styret\n lands_in:")).unwrap();
|
||||
assert!(internal.validate().unwrap_err().to_string().contains("public view"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_that_asks_more_than_the_person_will_tolerate_is_refused() {
|
||||
// Two required fields under a budget of one: the plan is refused
|
||||
// before any page exists, and the message says what to do.
|
||||
let plan: Plan = serde_yaml::from_str(&PLAN.replace("max_fields: 4", "max_fields: 1")).unwrap();
|
||||
let err = plan.validate().unwrap_err().to_string();
|
||||
assert!(err.contains("2 required field(s)") && err.contains("tolerate 1"), "{err}");
|
||||
// A budget of zero is not a budget.
|
||||
let plan: Plan = serde_yaml::from_str(&PLAN.replace("max_fields: 3", "max_fields: 0")).unwrap();
|
||||
assert!(plan.validate().unwrap_err().to_string().contains("at least 1"));
|
||||
// The budget is what the spec hands the verifier, not the form's size.
|
||||
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
|
||||
let files = render(&plan, &fallback_words(&plan)).unwrap();
|
||||
assert!(files["needs.yaml"].contains("max_fields: 4"));
|
||||
assert!(files["needs.yaml"].contains("max_fields: 3"));
|
||||
// A plan that says nothing about tolerance is not a plan.
|
||||
assert!(serde_yaml::from_str::<Plan>(&PLAN.replace(" max_fields: 4\n", "")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plan_with_a_bad_identifier_or_a_disagreeing_bucket_is_refused() {
|
||||
let bad: Plan = serde_yaml::from_str(&PLAN.replace("lost]", "avslått]")).unwrap();
|
||||
assert!(bad.validate().unwrap_err().to_string().contains("ASCII"));
|
||||
let clash = format!("{PLAN} - id: other\n who: w\n wants: x\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, won]\n done_when: [won]\n collects: []\n");
|
||||
let clash = format!("{PLAN} - id: other\n who: w\n wants: x\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, won]\n done_when: [won]\n max_fields: 1\n collects: []\n");
|
||||
let plan: Plan = serde_yaml::from_str(&clash).unwrap();
|
||||
assert!(plan.validate().unwrap_err().to_string().contains("must agree"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user