build: a need about another, answered from the board's own rows
`about: <need>` on a need puts its form on a page per record of the
other need (/status/<bucket>/[key]), links every row of the public
list to it, carries the record's id in a type: record field, takes a
signed-in person's address without asking, and lets `mails: { open:
owner }` reach whoever sent the record. The vel's plan uses it for
the lending board: notices stand from the moment they are sent, the
board hears, an answer goes straight to the poster, the queue is the
poster's inbox, and the redaktør or the clock takes down what stands.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
02235a7ef1
commit
4e1bc2027d
+40
-13
@@ -4,8 +4,12 @@
|
||||
# som frivillig. Komiteene er gruppene: en søknad om plass i en
|
||||
# komité velger blant de samme sju som har hver sin pult. Trafikk,
|
||||
# anlegg og oppslagstavla kan alle lese; kontaktopplysninger vises
|
||||
# ikke der. Nabohjelp begge veier, og oppslagstavla for deling av
|
||||
# utstyr holdes lite: én melding, styret legger den ut og tar den ned.
|
||||
# ikke der. Nabohjelp begge veier. Oppslagstavla for deling av utstyr
|
||||
# står ute med en gang: en nabo som kan låne ut, eller vil låne, sier
|
||||
# fra rett fra oppslaget, og den som la det ut hører fra dem. Køen er
|
||||
# innboksen til den som la ut; siden holder ikke rede på hvem som ble
|
||||
# valgt. Redaktøren, de som står som ansvarlige, tar ned det som står
|
||||
# igjen.
|
||||
business: Tomter Vel
|
||||
language: no
|
||||
contact: { name: Styret i Tomter Vel, email: post@tomtervel.no }
|
||||
@@ -223,22 +227,26 @@ needs:
|
||||
- { name: telefon, type: tel }
|
||||
- { name: kan, type: select, multiple: true, options: [Snømåking, Handling, Kjøring, Småfiks i huset, En prat og litt selskap, Det som trengs] }
|
||||
|
||||
# Oppslagstavla, holdt lite: én melding.
|
||||
# Oppslagstavla: ut med en gang, styret hører om det. Svar går
|
||||
# rett til den som la ut; køen er innboksen deres. Tas ned av den
|
||||
# som la ut, av redaktøren, eller av klokka etter en måned.
|
||||
- id: dele-utstyr
|
||||
who: en nabo som har noe å låne ut, eller trenger å låne noe
|
||||
wants: et oppslag som naboene ser
|
||||
wants: et oppslag naboene ser med en gang
|
||||
lands_in: oppslag
|
||||
door: naboer
|
||||
handled_by: styret
|
||||
stages: [open, lagt_ut, tatt_ned]
|
||||
stages_text: { open: "mottatt", lagt_ut: "lagt ut", tatt_ned: "tatt ned" }
|
||||
done_when: [lagt_ut, tatt_ned]
|
||||
success: [lagt_ut]
|
||||
# Den som la det ut kan ta det ned selv. Tavla er offentlig: det er
|
||||
# den naboene leser; navn og e-post vises ikke der.
|
||||
moves_by: { tatt_ned: [submitter, styret] }
|
||||
mails: { lagt_ut: submitter }
|
||||
handled_by: redaktor
|
||||
stages: [open, tatt_ned]
|
||||
stages_text: { open: "ute", tatt_ned: "tatt ned" }
|
||||
done_when: [tatt_ned]
|
||||
success: [tatt_ned]
|
||||
moves_by: { tatt_ned: [submitter, redaktor] }
|
||||
mails: { open: post@tomtervel.no }
|
||||
deadlines: { open: { days: 30, to: tatt_ned } }
|
||||
# Tavla er offentlig: det er den naboene leser; navn og e-post
|
||||
# vises ikke der.
|
||||
watched_by: [public]
|
||||
public_states: [open]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: navn, private: true }
|
||||
@@ -246,6 +254,25 @@ needs:
|
||||
- { name: har_eller_trenger, type: select, options: [Jeg har noe å låne ut, Jeg trenger å låne noe] }
|
||||
- { name: hva, type: textarea }
|
||||
|
||||
# Svaret på et oppslag: fra oppslagets egen rad på tavla, med
|
||||
# e-post eller innlogget, rett til den som la ut. Redaktøren ser
|
||||
# svarene og merker dem lest.
|
||||
- id: melde-seg
|
||||
who: en nabo som kan låne ut, eller vil låne, det et oppslag gjelder
|
||||
wants: at den som la ut oppslaget får vite det
|
||||
about: dele-utstyr
|
||||
lands_in: svar
|
||||
handled_by: redaktor
|
||||
stages: [open, lest]
|
||||
stages_text: { open: "sendt", lest: "lest" }
|
||||
done_when: [lest]
|
||||
success: [lest]
|
||||
mails: { open: owner }
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: hilsen, type: textarea, optional: true }
|
||||
|
||||
- id: utlegg
|
||||
who: en i en komité som har lagt ut for noe
|
||||
wants: pengene tilbake
|
||||
|
||||
+191
-26
@@ -180,6 +180,15 @@ pub struct PlanNeed {
|
||||
/// the front door.
|
||||
#[serde(default)]
|
||||
pub door: Option<String>,
|
||||
/// One answer per record of another need: a neighbour answering a
|
||||
/// notice on the board. The form sits on a page of its own per
|
||||
/// record (`/status/<bucket>/[key]`), reached from that record's
|
||||
/// row on the public list, and carries the record's id back in,
|
||||
/// so `mails: { open: owner }` reaches whoever sent that record.
|
||||
/// The other need must be `watched_by: [public]` and collect an
|
||||
/// `email` for `owner` to go anywhere.
|
||||
#[serde(default)]
|
||||
pub about: Option<String>,
|
||||
pub lands_in: String,
|
||||
pub handled_by: String,
|
||||
/// Every stage, first to last, finished states included.
|
||||
@@ -248,9 +257,10 @@ pub struct PlanNeed {
|
||||
#[serde(default)]
|
||||
pub attended_by: Option<String>,
|
||||
/// Who hears when a case enters a stage: `submitter` (the person
|
||||
/// who sent the form, at the email it collected) or one address
|
||||
/// (the treasurer, the board). Portal sends it the moment the
|
||||
/// case moves; the words model writes the subject and body.
|
||||
/// who sent the form, at the email it collected), `owner` (whoever
|
||||
/// sent the record this need is `about`) or one address (the
|
||||
/// treasurer, the board). Portal sends it the moment the case
|
||||
/// moves; the words model writes the subject and body.
|
||||
#[serde(default)]
|
||||
pub mails: BTreeMap<String, String>,
|
||||
/// A stage that makes the person a member of a group when their
|
||||
@@ -402,6 +412,25 @@ impl Plan {
|
||||
bail!(at("a door is a public way in; a need with enters_as has its group's page instead"));
|
||||
}
|
||||
}
|
||||
if let Some(about) = &need.about {
|
||||
let Some(parent) = self.needs.iter().find(|n| &n.id == about) else {
|
||||
bail!(at(&format!("about {about:?} is not one of the plan's needs")));
|
||||
};
|
||||
if parent.about.is_some() {
|
||||
bail!(at("about names a need that itself answers another; one level is what a board is"));
|
||||
}
|
||||
if !parent.watched_by.iter().any(|g| g == PUBLIC) {
|
||||
bail!(at(&format!("about {about:?}: its records are answered from the public list, so it needs watched_by: [public]")));
|
||||
}
|
||||
if need.door.is_some() || need.enters_as.is_some() {
|
||||
bail!(at("a need that answers another is reached from that one's list, not a door or a group's page"));
|
||||
}
|
||||
if need.mails.values().any(|to| to == "owner") && !parent.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) {
|
||||
bail!(at(&format!("mails owner, but {about:?} does not collect a required email to reach them at")));
|
||||
}
|
||||
} else if need.mails.values().any(|to| to == "owner") {
|
||||
bail!(at("mails owner, but answers no need: owner is whoever sent the record this need is about"));
|
||||
}
|
||||
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")));
|
||||
@@ -467,8 +496,11 @@ impl Plan {
|
||||
if !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) {
|
||||
bail!(at("a mail to the submitter goes to the email the form collected, so the form must collect a required `email` of type email"));
|
||||
}
|
||||
} else if to == "owner" {
|
||||
// Checked with `about` below: owner is whoever sent
|
||||
// the record this need answers.
|
||||
} else if !to.contains('@') || to.contains(char::is_whitespace) {
|
||||
bail!(at(&format!("mails {state:?} must go to `submitter` or one address, not {to:?}")));
|
||||
bail!(at(&format!("mails {state:?} must go to `submitter`, `owner` or one address, not {to:?}")));
|
||||
}
|
||||
if self.mail.is_none() {
|
||||
bail!(at("has mails, so the plan needs `mail: { from: ... }` saying who they come from"));
|
||||
@@ -611,7 +643,18 @@ impl Plan {
|
||||
/// 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()
|
||||
self.doors.len() + self.needs.iter().filter(|n| n.on_front_door()).count()
|
||||
}
|
||||
|
||||
/// The need this one answers records of, if any.
|
||||
fn parent_of<'a>(&'a self, need: &PlanNeed) -> Option<&'a PlanNeed> {
|
||||
let id = need.about.as_deref()?;
|
||||
self.needs.iter().find(|n| n.id == id)
|
||||
}
|
||||
|
||||
/// The needs that answer records of `need`, in plan order.
|
||||
fn answers_to<'a>(&'a self, need: &PlanNeed) -> Vec<&'a PlanNeed> {
|
||||
self.needs.iter().filter(|n| n.about.as_deref() == Some(need.id.as_str())).collect()
|
||||
}
|
||||
|
||||
fn needs_behind(&self, door: &str) -> Vec<&PlanNeed> {
|
||||
@@ -736,6 +779,24 @@ fn humanize(id: &str) -> String {
|
||||
words
|
||||
}
|
||||
|
||||
impl PlanNeed {
|
||||
/// A public need that is not behind a door and answers nothing:
|
||||
/// its form is on the front door itself.
|
||||
fn on_front_door(&self) -> bool {
|
||||
self.enters_as.is_none() && self.door.is_none() && self.about.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// The page where one record of `bucket` is answered: under the
|
||||
/// public list, so the way down the tree leads back to the board.
|
||||
fn answer_page(bucket: &str) -> String {
|
||||
format!("/status/{bucket}/[key]")
|
||||
}
|
||||
|
||||
fn answer_page_path(bucket: &str) -> String {
|
||||
format!("questions/status/{bucket}/[key].yaml")
|
||||
}
|
||||
|
||||
fn public_followup(need: &PlanNeed) -> String {
|
||||
format!("/received/{}", need.id)
|
||||
}
|
||||
@@ -789,7 +850,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
})
|
||||
};
|
||||
let mut words = BTreeMap::new();
|
||||
let direct: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none() && n.door.is_none()).collect();
|
||||
let direct: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.on_front_door()).collect();
|
||||
// The opener: what the business does, asking for nothing. From
|
||||
// the plan's `does`, or one feature per public need.
|
||||
let does: Map<String, Value> = if plan.does.is_empty() {
|
||||
@@ -860,6 +921,20 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// A page per record of a public need, where another need answers
|
||||
// it: the record's own words come from the record, so the page
|
||||
// only has to ask and hold the form.
|
||||
for need in plan.needs.iter().filter(|n| n.about.is_some()) {
|
||||
let parent = plan.parent_of(need).expect("validated");
|
||||
words.insert(
|
||||
answer_page_path(&parent.lands_in),
|
||||
json!({
|
||||
"question": format!("{}?", capitalize(&need.wants)),
|
||||
"description": format!("For {}.", need.who),
|
||||
"form": form(need),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for need in &plan.needs {
|
||||
let path = match &need.enters_as {
|
||||
None => format!("questions/received/{}.yaml", need.id),
|
||||
@@ -976,6 +1051,10 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
"list_title": humanize(bucket),
|
||||
"list_note": loc(lang, "Each row is one case and the stage it has reached. Names and contact details are not shown here.", "Hver rad er én sak og hvor langt den har kommet. Navn og kontaktopplysninger vises ikke her."),
|
||||
"empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."),
|
||||
// The link on every row to the page where another
|
||||
// need answers it, when one does: what the reader
|
||||
// presses, so it says what they get to do.
|
||||
"answer": needs.iter().flat_map(|n| plan.answers_to(n)).next().map(|a| capitalize(&a.wants)).unwrap_or_default(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1230,7 +1309,7 @@ const FRONT_DOOR_LATEST: usize = 3;
|
||||
/// stages the plan opens, contact details and private fields pruned,
|
||||
/// and at most `limit` records when given. Portal filters by `states`
|
||||
/// while listing, before `jq` runs, so the limit counts shown records.
|
||||
fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option<usize>) -> Value {
|
||||
fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option<usize>, answer: &str) -> Value {
|
||||
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()));
|
||||
@@ -1243,13 +1322,19 @@ fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option
|
||||
"sort_by(.submitted_ms) | reverse{take} | .[] | .responses |= {{{}}}",
|
||||
shown.iter().map(|f| format!("{f}: .{f}")).collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
json!({
|
||||
let mut resource = json!({
|
||||
"source": { "kind": "kv", "bucket": bucket },
|
||||
"public": true,
|
||||
"states": needs[0].public_states,
|
||||
"empty": empty,
|
||||
"jq": jq,
|
||||
})
|
||||
});
|
||||
// Every row links to the page where a record is answered, when a
|
||||
// need answers this one's records.
|
||||
if !answer.trim().is_empty() {
|
||||
resource["link"] = json!({ "to": answer_page(bucket).replace("[key]", "{id}"), "label": answer.trim() });
|
||||
}
|
||||
resource
|
||||
}
|
||||
|
||||
/// What a public list holds, in one sentence, honest about its filter.
|
||||
@@ -1435,17 +1520,27 @@ fn s(v: &Value) -> &str {
|
||||
}
|
||||
|
||||
fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Value {
|
||||
let parent = plan.parent_of(need);
|
||||
let mut requirements: Vec<Value> = 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); }
|
||||
// Answering a record is done by neighbours as well as members:
|
||||
// someone signed in is not asked for what the site knows.
|
||||
if parent.is_some() && f.name == "email" && f.kind == "email" { r["value"] = json!("{user.email}"); }
|
||||
r
|
||||
}).collect();
|
||||
// The record this answer is about, carried in from the page's own
|
||||
// address: nobody types a record id.
|
||||
if let Some(parent) = parent {
|
||||
requirements.push(json!({ "name": parent.lands_in, "type": "record", "of": parent.lands_in, "value": "{key}" }));
|
||||
}
|
||||
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<_>>(),
|
||||
"requirements": requirements,
|
||||
});
|
||||
if !s(&w["form_description"]).trim().is_empty() {
|
||||
feature["description"] = json!(s(&w["form_description"]).trim());
|
||||
@@ -1522,6 +1617,13 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
let mut state = json!({ "event": event_for(need, st) });
|
||||
if let Some(to) = need.mails.get(st) {
|
||||
let w = &mail_words[bucket][st];
|
||||
// `owner` is whoever sent the record this need
|
||||
// answers: the address that record holds, reached
|
||||
// through the record field the form carries.
|
||||
let to = match (to.as_str(), plan.parent_of(need)) {
|
||||
("owner", Some(parent)) => format!("{{{}.email}}", parent.lands_in),
|
||||
_ => to.clone(),
|
||||
};
|
||||
state["mail"] = json!({ "to": to, "subject": s(&w["subject"]), "body": s(&w["body"]) });
|
||||
}
|
||||
if let Some(group) = need.grants.get(st) {
|
||||
@@ -1576,7 +1678,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
let mut f = card(w);
|
||||
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim(), l => l };
|
||||
f["description"] = json!(format!("{} [{link}](/status/{bucket})", s(&w["description"]).trim()).trim().to_string());
|
||||
f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), Some(FRONT_DOOR_LATEST));
|
||||
f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), Some(FRONT_DOOR_LATEST), s(&words[&public_view_path(bucket)]["answer"]));
|
||||
shopping.push(f);
|
||||
}
|
||||
if !shopping.is_empty() {
|
||||
@@ -1612,7 +1714,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
alternatives.extend(
|
||||
plan.needs
|
||||
.iter()
|
||||
.filter(|n| n.enters_as.is_none() && n.door.is_none())
|
||||
.filter(|n| n.on_front_door())
|
||||
.map(|n| form_alternative(plan, n, &front["needs"][&n.id], &public_followup(n))),
|
||||
);
|
||||
if plan.front_door_count() > MAX_FRONT_DOOR {
|
||||
@@ -1642,6 +1744,24 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
);
|
||||
}
|
||||
|
||||
// A page per record of a board, holding the form that answers it.
|
||||
// Dynamic: portal serves `/status/<bucket>/<id>` from it, and the
|
||||
// form's record field carries that id.
|
||||
for need in plan.needs.iter().filter(|n| n.about.is_some()) {
|
||||
let parent = plan.parent_of(need).expect("validated");
|
||||
let path = answer_page_path(&parent.lands_in);
|
||||
let w = &words[&path];
|
||||
files.insert(
|
||||
path,
|
||||
json!({
|
||||
"name": s(&w["question"]),
|
||||
"description": s(&w["description"]),
|
||||
"responsible": responsible,
|
||||
"alternatives": [form_alternative(plan, need, &w["form"], &public_followup(need))],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for need in &plan.needs {
|
||||
// No "back" alternative: portal's way down the tree (the link at
|
||||
// the foot of every page) takes a visitor one level down from
|
||||
@@ -1796,7 +1916,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
{
|
||||
"name": s(&w["heading"]),
|
||||
"description": s(&w["list_description"]),
|
||||
"features": [{ "name": s(&w["list_title"]), "description": s(&w["list_note"]), "resource": public_resource(bucket, &needs, s(&w["empty"]), None) }],
|
||||
"features": [{ "name": s(&w["list_title"]), "description": s(&w["list_note"]), "resource": public_resource(bucket, &needs, s(&w["empty"]), None, s(&w["answer"])) }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -1845,10 +1965,13 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
"needs": plan.needs.iter().map(|n| {
|
||||
// `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 (enters, heading, steps) = match (&n.enters_as, &n.door, plan.parent_of(n)) {
|
||||
(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),
|
||||
// An answer enters on the record's own page, which
|
||||
// the board's row links to.
|
||||
(None, None, Some(parent)) => (answer_page(&parent.lands_in), s(&words[&answer_page_path(&parent.lands_in)]["form"]["heading"]).to_string(), 1),
|
||||
(None, None, None) => ("/".to_string(), s(&front["needs"][&n.id]["heading"]).to_string(), 1),
|
||||
};
|
||||
let mut need = json!({
|
||||
"id": n.id, "who": n.who, "wants": n.wants, "enters": enters, "via": heading,
|
||||
@@ -1894,6 +2017,8 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
|
||||
"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. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons."
|
||||
} else if path.ends_with("/[key].yaml") {
|
||||
"a page one record of a public list opens to: a neighbour has read one notice on the board and is answering it. `question` asks them, and `form` is the form they fill; the record itself is shown by portal, so say nothing about what it contains."
|
||||
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
|
||||
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next."
|
||||
} else {
|
||||
@@ -2044,7 +2169,7 @@ 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, door: None,
|
||||
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, door: None, about: 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![],
|
||||
@@ -2760,6 +2885,46 @@ mod invite_tests {
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
}
|
||||
|
||||
const BOARD: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\nmail: { from: v@v.example }\nlanguage: no\nneeds:\n - id: notice\n who: en nabo som har noe å låne ut\n wants: et oppslag naboene ser\n lands_in: oppslag\n handled_by: redaktor\n stages: [open, tatt_ned]\n done_when: [tatt_ned]\n moves_by: { tatt_ned: [submitter, redaktor] }\n mails: { open: s@v.example }\n watched_by: [public]\n public_states: [open]\n max_fields: 3\n collects:\n - { name: email, type: email }\n - { name: hva, type: textarea }\n - id: answer\n who: en nabo som kan låne ut det\n wants: at den som la ut oppslaget får vite det\n about: notice\n lands_in: svar\n handled_by: redaktor\n stages: [open, lest]\n done_when: [lest]\n mails: { open: owner }\n max_fields: 1\n collects:\n - { name: email, type: email }\n";
|
||||
|
||||
#[test]
|
||||
fn a_need_about_another_is_answered_from_the_boards_own_rows() {
|
||||
let plan: Plan = serde_yaml::from_str(BOARD).unwrap();
|
||||
plan.validate().unwrap();
|
||||
let files = render(&plan, &fallback_words(&plan)).unwrap();
|
||||
// The board links every row to the record's own page, on the
|
||||
// front door and on the full list alike.
|
||||
let board: Value = serde_yaml::from_str(&files["questions/status/oppslag/index.yaml"]).unwrap();
|
||||
let link = &board["alternatives"][0]["features"][0]["resource"]["link"];
|
||||
assert_eq!(link["to"], "/status/oppslag/{id}");
|
||||
assert_eq!(link["label"], "At den som la ut oppslaget får vite det");
|
||||
assert!(files["questions/index.yaml"].contains("to: /status/oppslag/{id}"));
|
||||
// The page per record: a dynamic page holding the form, which
|
||||
// carries the record's id and takes a signed-in person's address.
|
||||
let page: Value = serde_yaml::from_str(&files["questions/status/oppslag/[key].yaml"]).unwrap();
|
||||
let alt = &page["alternatives"][0];
|
||||
assert_eq!(alt["record_as"], "svar");
|
||||
assert_eq!(alt["action"], "/received/answer");
|
||||
let reqs = alt["features"].as_array().unwrap().last().unwrap()["requirements"].as_array().unwrap();
|
||||
assert_eq!(reqs[0]["value"], "{user.email}");
|
||||
assert_eq!(reqs[1]["type"], "record");
|
||||
assert_eq!(reqs[1]["of"], "oppslag");
|
||||
assert_eq!(reqs[1]["value"], "{key}");
|
||||
// The answer is not on the front door, and its mail reaches
|
||||
// the poster through the record field.
|
||||
assert!(!files["questions/index.yaml"].contains("record_as: svar"));
|
||||
assert!(files["aggregates.yaml"].contains("to: '{oppslag.email}'"), "{}", files["aggregates.yaml"]);
|
||||
// The verifier is told where an answer enters.
|
||||
let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap();
|
||||
assert_eq!(needs["needs"][1]["enters"], "/status/oppslag/[key]");
|
||||
// A need that answers one nobody can see is refused, and so is
|
||||
// owner mail with nobody to send it to.
|
||||
let hidden: Plan = serde_yaml::from_str(&BOARD.replace(" watched_by: [public]\n", "")).unwrap();
|
||||
assert!(hidden.validate().unwrap_err().to_string().contains("watched_by"));
|
||||
let orphan: Plan = serde_yaml::from_str(&BOARD.replace(" about: notice\n", "")).unwrap();
|
||||
assert!(orphan.validate().unwrap_err().to_string().contains("owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inviting_desks_get_a_form_per_group_and_a_list_of_invites_sent() {
|
||||
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user