Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2feeea0810 | ||
|
|
6b91258ebe | ||
|
|
ae119af987 | ||
|
|
92dd9bfc79 | ||
|
|
2cc74e3354 | ||
|
|
7b54c4c6d7 | ||
|
|
951fac9aec |
@@ -38,6 +38,25 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --features ssr
|
run: cargo test --features ssr
|
||||||
|
|
||||||
|
# Every plan under plans/ built deterministically and held to the
|
||||||
|
# lint, both by this very portal: a builder change that renders a
|
||||||
|
# site the lint rejects fails here, not at the next training run.
|
||||||
|
# (portal-trainer's CI did this until the trainer moved in.)
|
||||||
|
- name: Build and lint every plan
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cargo build --features ssr --bin portal
|
||||||
|
bin="$CARGO_TARGET_DIR/debug/portal"
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
lint="$tmp/portal-lint"
|
||||||
|
printf '#!/bin/sh\nexec %s lint "$@"\n' "$bin" > "$lint" && chmod +x "$lint"
|
||||||
|
for plan in plans/*.yaml; do
|
||||||
|
name=$(basename "$plan" .yaml)
|
||||||
|
echo "== $plan"
|
||||||
|
rm -rf "$tmp/plan-$name"
|
||||||
|
"$bin" build --plan "$plan" --out "$tmp/plan-$name" --lint "$lint"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Stop the throwaway JetStream
|
- name: Stop the throwaway JetStream
|
||||||
if: always()
|
if: always()
|
||||||
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
|
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ One line per change, grouped by the release that shipped it. Newest
|
|||||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||||
content repos pin the tag they run (see the end of this file).
|
content repos pin the tag they run (see the end of this file).
|
||||||
|
|
||||||
|
## 0.6.17 (2026-10-02)
|
||||||
|
|
||||||
|
- A desk shows every record still moving and only the three most recently finished (a final state: no move out of it), with "Show older" for three more at a time. A desk that has run for a year is still about the work in hand.
|
||||||
|
- Mail variables are one set for every kind of mail (docs: Mail). Every mail has `{site}` and `{site_name}`; a state mail also has `{link}`, `{self_link}` and `{desk_link}`: the reader's own page for the record (a self-service page with `?chain=&email=`, when the record's state allows a move there), the desk that moves it, and `{link}` choosing between them by who the mail is to. `{link}` in a state mail used to render as nothing.
|
||||||
|
- The lint follows every state of every bucket a need lands in: a state no desk, link or clock reaches fails; a state a record can reach and never leave fails, after "done" too; a declared move nobody offers warns; a desk gated to one group on the page and another on its list warns; working stages must follow each other in the business's order; a mail from an unreachable state warns, and one naming a field no form collects fails. Clocks (`after:`) count as moves, and a visitor's own link only from the states it names. Each finding ends in the fix: the `{ from, to, label }` and the desk page it goes on, or the group that already has the button. A schema error no longer hides the needs findings.
|
||||||
|
|
||||||
## 0.6.16 (2026-10-01)
|
## 0.6.16 (2026-10-01)
|
||||||
|
|
||||||
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
|
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
|
||||||
|
|||||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.6.15"
|
version = "0.6.17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.6.15"
|
version = "0.6.17"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
- [Getting started](getting-started.md)
|
- [Getting started](getting-started.md)
|
||||||
- [Content reference](content-reference.md)
|
- [Content reference](content-reference.md)
|
||||||
- [State machines](state-machines.md)
|
- [State machines](state-machines.md)
|
||||||
|
- [Mail](mail.md)
|
||||||
- [Access](access.md)
|
- [Access](access.md)
|
||||||
- [Storylines](storylines.md)
|
- [Storylines](storylines.md)
|
||||||
- [Memberships](memberships.md)
|
- [Memberships](memberships.md)
|
||||||
|
|||||||
@@ -285,6 +285,14 @@ markdown on stricter rules than the site's own: paragraphs, line
|
|||||||
breaks as typed, lists, emphasis and links to https, mailto or tel
|
breaks as typed, lists, emphasis and links to https, mailto or tel
|
||||||
(marked `nofollow ugc`); no images, no raw HTML.
|
(marked `nofollow ugc`); no images, no raw HTML.
|
||||||
|
|
||||||
|
A desk (a list with `transitions`) shows every record still moving,
|
||||||
|
and of those in a final state - one the state machine has no move out
|
||||||
|
of - only the three most recently decided, newest first. "Show older"
|
||||||
|
under the list brings three more each time it is pressed. The desk
|
||||||
|
stays about the work in hand however long the site has run; nothing
|
||||||
|
is hidden from a read-only list, and `leaves_after` still takes a
|
||||||
|
record out of every list altogether.
|
||||||
|
|
||||||
`states` and `where` narrow a listing. They matter most for a register
|
`states` and `where` narrow a listing. They matter most for a register
|
||||||
that is public by law — public about what was decided, not about what
|
that is public by law — public about what was decided, not about what
|
||||||
was withdrawn — while the desk working the same bucket is a different
|
was withdrawn — while the desk working the same bucket is a different
|
||||||
|
|||||||
+15
-1
@@ -34,7 +34,21 @@ in `needs.yaml`, when it has one:
|
|||||||
- the handling group holds a desk over that bucket, and every
|
- the handling group holds a desk over that bucket, and every
|
||||||
finished state is reachable through the buttons it (and any
|
finished state is reachable through the buttons it (and any
|
||||||
`also_moved_by` group) offers; no record strands on the way;
|
`also_moved_by` group) offers; no record strands on the way;
|
||||||
- every stage the business named is a state of the machine.
|
- every stage the business named is a state of the machine, and each
|
||||||
|
working stage can follow the one before it;
|
||||||
|
- across each bucket, whichever need brought a record in: every state
|
||||||
|
is somewhere a record can get to, no state a record gets to holds it
|
||||||
|
with no way on (before done or after), every declared move is a
|
||||||
|
button, a visitor's own link or a clock (a move nobody offers warns),
|
||||||
|
no desk is gated to one group on the page and another on its list,
|
||||||
|
and every mail goes out from a state records reach and names only
|
||||||
|
fields the forms collect.
|
||||||
|
|
||||||
|
Every finding says what to change and where: the missing
|
||||||
|
`{ from, to, label }` and the desk page it goes on, the group that
|
||||||
|
already has the button, or the edge aggregates.yaml lacks. A schema
|
||||||
|
error no longer hides the needs findings - both are printed, so one
|
||||||
|
round can fix them together.
|
||||||
|
|
||||||
A need marked `planned` warns instead of failing. Exit status is the
|
A need marked `planned` warns instead of failing. Exit status is the
|
||||||
verdict, so a content repo's CI is one line.
|
verdict, so a content repo's CI is one line.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Mail
|
||||||
|
|
||||||
|
Portal writes every mail itself and hands it to gdo to deliver
|
||||||
|
(`portal.mail.send`); a reply comes back as a note beside the record
|
||||||
|
(`portal.mail.received`). A mail is a subject and a markdown body with
|
||||||
|
`{placeholders}`, sent as text with an HTML alternative. The kinds of
|
||||||
|
mail are the ones content declares on a state (see
|
||||||
|
[State machines](state-machines.md#mail)), the invite and its
|
||||||
|
rewording (`invite: { group, mail }`, `mail.invite` in site.yaml), the
|
||||||
|
note to a person named responsible for a question, and the answers to
|
||||||
|
a suggested change.
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
A placeholder is `{` letters, digits, `_`, `-` or `.` `}`. One that
|
||||||
|
names nothing renders as nothing; a brace that opens no name stays a
|
||||||
|
brace. The variables come in layers, so a template written for one
|
||||||
|
kind of mail reads the same in another.
|
||||||
|
|
||||||
|
**On every mail**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `{site}` | the instance's public URL, no trailing slash |
|
||||||
|
| `{site_name}` | site.yaml's `title` |
|
||||||
|
|
||||||
|
**On a mail about a record** (every state mail)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `{field}` | any field of the record, as the form sent it |
|
||||||
|
| `{chain}` | the record's id |
|
||||||
|
| `{email}` | the record's `email` field, trimmed |
|
||||||
|
| `{bucket}`, `{state}` | where it is and the state it just entered |
|
||||||
|
| `{notice.field}` | a field of the record this one belongs to, through its `type: record` field `notice` |
|
||||||
|
| `{self_link}` | the record's own page for the person it belongs to: a page offering a self-service move (`self_transition`) on the bucket from the state the record is in, with the `?chain=&email=` that lets them act without signing in. Empty when there is no such page or no address |
|
||||||
|
| `{desk_link}` | the desk that moves the record: the page whose list over the bucket has `transitions`, else one that reads it. Empty when no page reads the bucket |
|
||||||
|
| `{link}` | where this reader acts: `{self_link}` in a mail `to: submitter`, `{desk_link}` in one to anyone else, and `{site}` when that link is empty |
|
||||||
|
|
||||||
|
`{link}` is the one to reach for. "Se meldingen og avtal befaring:
|
||||||
|
{link}" sent to the caretaker opens the desk the report is on; "Feilen
|
||||||
|
du meldte om er utbedret. {link}" sent to the tenant opens their own
|
||||||
|
case where they can still act on it, or the site when there is
|
||||||
|
nothing left for them to do.
|
||||||
|
|
||||||
|
**On an invite and the note to someone responsible**, beside every
|
||||||
|
field of the invite's form: `{name}`, `{username}`, `{email}`,
|
||||||
|
`{group}` (invite), `{questions}` (responsible), `{expires}`, and
|
||||||
|
`{link}`, which here is the one-time sign-in link.
|
||||||
|
|
||||||
|
## What the lint holds a mail to
|
||||||
|
|
||||||
|
A state mail that sends from a state no record reaches warns; one that
|
||||||
|
names a field no form recording into the bucket collects fails, since
|
||||||
|
it would go out with a blank where the field should be. The lint
|
||||||
|
knows every variable above, so `{link}` and the rest are never
|
||||||
|
flagged. `to: submitter` requires a required `email` on every form
|
||||||
|
filling the bucket, and `to: "{notice.email}"` requires the `notice`
|
||||||
|
field.
|
||||||
@@ -57,7 +57,9 @@ a record arrives in it.
|
|||||||
```
|
```
|
||||||
|
|
||||||
`{field}` is any field of the record, plus `{site}`, `{chain}`,
|
`{field}` is any field of the record, plus `{site}`, `{chain}`,
|
||||||
`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent
|
`{email}`, `{bucket}`, `{state}` and `{link}` - the page where the
|
||||||
|
reader acts on the record, their own or the desk's (every variable is
|
||||||
|
in [Mail](mail.md#variables)). The body is markdown; it is sent
|
||||||
as text with an HTML alternative. `to: submitter` means the record's
|
as text with an HTML alternative. `to: submitter` means the record's
|
||||||
`email` field, and the lint then requires every form filling that
|
`email` field, and the lint then requires every form filling that
|
||||||
bucket to collect a required `email`.
|
bucket to collect a required `email`.
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ pub struct Answer {
|
|||||||
/// not see before.
|
/// not see before.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub notes: Vec<Note>,
|
pub notes: Vec<Note>,
|
||||||
|
/// Set by a desk listing, never stored: the record is in a final
|
||||||
|
/// state (one with no move out). A desk shows the newest few of
|
||||||
|
/// those and offers the rest on request (`resource::cap_finished`).
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub finished: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One reply on a record. It lives here rather than with the mail
|
/// One reply on a record. It lives here rather than with the mail
|
||||||
@@ -97,6 +102,7 @@ pub async fn store_answer(
|
|||||||
storyline,
|
storyline,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
};
|
};
|
||||||
let data = serde_json::to_vec(&answer)?;
|
let data = serde_json::to_vec(&answer)?;
|
||||||
@@ -537,6 +543,7 @@ mod tests {
|
|||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
},
|
},
|
||||||
Answer {
|
Answer {
|
||||||
id: "def456".to_string(),
|
id: "def456".to_string(),
|
||||||
@@ -549,6 +556,7 @@ mod tests {
|
|||||||
decided_ms: Some(1_700_000_002_000),
|
decided_ms: Some(1_700_000_002_000),
|
||||||
decided_by: Some("bl".to_string()),
|
decided_by: Some("bl".to_string()),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let value = serde_json::Value::Array(
|
let value = serde_json::Value::Array(
|
||||||
|
|||||||
+43
-6
@@ -1936,18 +1936,26 @@ fn ResourceFeature(
|
|||||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||||
transition_batch: ServerAction<TransitionAnswers>,
|
transition_batch: ServerAction<TransitionAnswers>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
// A desk shows the newest few finished records; "show older" asks
|
||||||
|
// for more (see `resource::cap_finished`).
|
||||||
|
let finished_shown = RwSignal::new(crate::resource::FINISHED_SHOWN);
|
||||||
let data = Resource::new(
|
let data = Resource::new(
|
||||||
{
|
{
|
||||||
let question_id = question_id.clone();
|
let question_id = question_id.clone();
|
||||||
let alternative = alternative.clone();
|
let alternative = alternative.clone();
|
||||||
let feature_name = feature_name.clone();
|
let feature_name = feature_name.clone();
|
||||||
move || (question_id.clone(), alternative.clone(), feature_name.clone())
|
move || (question_id.clone(), alternative.clone(), feature_name.clone(), finished_shown.get())
|
||||||
|
},
|
||||||
|
// Forwarding sibling requirement values as query params (for a
|
||||||
|
// resource parameterized by other form fields) is deferred, see
|
||||||
|
// the resource-backed multi-select requirement UI follow-up.
|
||||||
|
|(q, a, f, shown)| {
|
||||||
|
let mut params = std::collections::HashMap::new();
|
||||||
|
if shown != crate::resource::FINISHED_SHOWN {
|
||||||
|
params.insert(crate::resource::FINISHED_PARAM.to_string(), shown.to_string());
|
||||||
|
}
|
||||||
|
get_resource(q, a, f, params)
|
||||||
},
|
},
|
||||||
// Empty params for now - forwarding sibling requirement values
|
|
||||||
// as query params (for a resource parameterized by other form
|
|
||||||
// fields) is deferred, see the resource-backed multi-select
|
|
||||||
// requirement UI follow-up.
|
|
||||||
|(q, a, f)| get_resource(q, a, f, std::collections::HashMap::new()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// The shared batch button lives on `AlternativeCard`, above every
|
// The shared batch button lives on `AlternativeCard`, above every
|
||||||
@@ -1982,6 +1990,7 @@ fn ResourceFeature(
|
|||||||
value=value
|
value=value
|
||||||
pending_transitions=pending_transitions
|
pending_transitions=pending_transitions
|
||||||
transition_batch=transition_batch
|
transition_batch=transition_batch
|
||||||
|
finished_shown=finished_shown
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
@@ -2011,6 +2020,7 @@ fn ResourceValue(
|
|||||||
value: serde_json::Value,
|
value: serde_json::Value,
|
||||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||||
transition_batch: ServerAction<TransitionAnswers>,
|
transition_batch: ServerAction<TransitionAnswers>,
|
||||||
|
finished_shown: RwSignal<usize>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
// A jq like `.[] | {...}` over an empty list yields no output at
|
// A jq like `.[] | {...}` over an empty list yields no output at
|
||||||
// all - that arrives as null, and it's the same silence as [].
|
// all - that arrives as null, and it's the same silence as [].
|
||||||
@@ -2034,6 +2044,32 @@ fn ResourceValue(
|
|||||||
let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
|
let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
|
||||||
open(&a.state).cmp(&open(&b.state)).then(when)
|
open(&a.state).cmp(&open(&b.state)).then(when)
|
||||||
});
|
});
|
||||||
|
// The desk sent one finished record more than it shows, when
|
||||||
|
// there are older ones: drop the oldest and offer the rest.
|
||||||
|
let shown = finished_shown.get_untracked();
|
||||||
|
let older = answers.iter().filter(|a| a.finished).count() > shown;
|
||||||
|
if older {
|
||||||
|
if let Some(oldest) = answers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, a)| a.finished)
|
||||||
|
.min_by_key(|(_, a)| a.decided_ms.unwrap_or(a.submitted_ms))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
{
|
||||||
|
answers.remove(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let show_older = older.then(|| {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="show-older"
|
||||||
|
on:click=move |_| finished_shown.update(|n| *n += crate::resource::FINISHED_SHOWN)
|
||||||
|
>
|
||||||
|
{move || t(&lang().get(), "show_older")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
});
|
||||||
return view! {
|
return view! {
|
||||||
<div class="answer-list" class:answer-cards=cards>
|
<div class="answer-list" class:answer-cards=cards>
|
||||||
<For
|
<For
|
||||||
@@ -2060,6 +2096,7 @@ fn ResourceValue(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{show_older}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
.into_any();
|
.into_any();
|
||||||
|
|||||||
+1
-1
@@ -194,7 +194,7 @@ aggregates:
|
|||||||
Answer {
|
Answer {
|
||||||
id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
|
id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
|
||||||
responses: serde_json::json!({}), state: state.into(),
|
responses: serde_json::json!({}), state: state.into(),
|
||||||
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![],
|
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![], finished: false,
|
||||||
storyline: 0,
|
storyline: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
|||||||
("en", "sign_in") => "Sign in",
|
("en", "sign_in") => "Sign in",
|
||||||
("en", "sign_out") => "Sign out",
|
("en", "sign_out") => "Sign out",
|
||||||
("en", "nothing_here_yet") => "Nothing here yet.",
|
("en", "nothing_here_yet") => "Nothing here yet.",
|
||||||
|
("en", "show_older") => "Show older",
|
||||||
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
|
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
|
||||||
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
|
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
|
||||||
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
|
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
|
||||||
@@ -60,6 +61,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
|||||||
("no", "sign_in") => "Logg inn",
|
("no", "sign_in") => "Logg inn",
|
||||||
("no", "sign_out") => "Logg ut",
|
("no", "sign_out") => "Logg ut",
|
||||||
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
||||||
|
("no", "show_older") => "Vis eldre",
|
||||||
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
|
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
|
||||||
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
|
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
|
||||||
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
|
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
|
||||||
@@ -114,6 +116,7 @@ mod tests {
|
|||||||
"sign_in",
|
"sign_in",
|
||||||
"sign_out",
|
"sign_out",
|
||||||
"nothing_here_yet",
|
"nothing_here_yet",
|
||||||
|
"show_older",
|
||||||
"upload_failed",
|
"upload_failed",
|
||||||
"upload_too_large",
|
"upload_too_large",
|
||||||
"upload_unavailable",
|
"upload_unavailable",
|
||||||
|
|||||||
+23
-12
@@ -126,11 +126,33 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("FAIL: {e}");
|
eprintln!("FAIL: {e}");
|
||||||
|
// One schema error is no reason to hide the rest: the needs
|
||||||
|
// pass reads the same content and says what else is wrong,
|
||||||
|
// so a fix round can take them all at once.
|
||||||
|
if let Some(file) = needs_raw.as_deref().and_then(|raw| needs::parse(raw).ok()) {
|
||||||
|
print_findings(&needs::check(&file, &questions, &aggregates_map));
|
||||||
|
}
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each finding on its own line, FAIL or WARN first; the count of FAILs.
|
||||||
|
fn print_findings(findings: &[needs::Finding]) -> usize {
|
||||||
|
let mut failed = 0;
|
||||||
|
for finding in findings {
|
||||||
|
let level = match finding.level {
|
||||||
|
needs::Level::Fail => {
|
||||||
|
failed += 1;
|
||||||
|
"FAIL"
|
||||||
|
}
|
||||||
|
needs::Level::Warn => "WARN",
|
||||||
|
};
|
||||||
|
eprintln!("{level}: {} {:?}: {}", finding.scope, finding.need, finding.message);
|
||||||
|
}
|
||||||
|
failed
|
||||||
|
}
|
||||||
|
|
||||||
/// The business-needs pass (see `crate::lint::needs`): structure always,
|
/// The business-needs pass (see `crate::lint::needs`): structure always,
|
||||||
/// then the wording tasks written out and/or an engine's answers
|
/// then the wording tasks written out and/or an engine's answers
|
||||||
/// scored, when asked. A repo without `needs.yaml` skips all of it.
|
/// scored, when asked. A repo without `needs.yaml` skips all of it.
|
||||||
@@ -158,18 +180,7 @@ fn check_needs(
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let findings = needs::check(&file, questions, aggregates_map);
|
let failed = print_findings(&needs::check(&file, questions, aggregates_map));
|
||||||
let mut failed = 0;
|
|
||||||
for finding in &findings {
|
|
||||||
let level = match finding.level {
|
|
||||||
needs::Level::Fail => {
|
|
||||||
failed += 1;
|
|
||||||
"FAIL"
|
|
||||||
}
|
|
||||||
needs::Level::Warn => "WARN",
|
|
||||||
};
|
|
||||||
eprintln!("{level}: need {:?}: {}", finding.need, finding.message);
|
|
||||||
}
|
|
||||||
for need in &file.needs {
|
for need in &file.needs {
|
||||||
if let Some(path) = needs::path_for(questions, need) {
|
if let Some(path) = needs::path_for(questions, need) {
|
||||||
let steps: Vec<String> = path
|
let steps: Vec<String> = path
|
||||||
|
|||||||
+439
-27
@@ -137,9 +137,13 @@ pub enum Level {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Finding {
|
pub struct Finding {
|
||||||
|
/// The need's id, or the bucket's name when `scope` is "bucket".
|
||||||
pub need: String,
|
pub need: String,
|
||||||
pub level: Level,
|
pub level: Level,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// "need" for a need's own path; "bucket" for what holds for every
|
||||||
|
/// record in a bucket whichever need brought it in.
|
||||||
|
pub scope: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -255,6 +259,10 @@ pub fn path_for(questions: &Questions, need: &Need) -> Option<Vec<Step>> {
|
|||||||
struct Desk {
|
struct Desk {
|
||||||
page: String,
|
page: String,
|
||||||
group: Option<String>,
|
group: Option<String>,
|
||||||
|
/// The page's own `qualifies`, kept apart from the resource's
|
||||||
|
/// `requires_group`: when both are set and differ, only members of
|
||||||
|
/// both ever see the buttons.
|
||||||
|
page_group: Option<String>,
|
||||||
/// (from, to, button label)
|
/// (from, to, button label)
|
||||||
moves: Vec<(String, String, String)>,
|
moves: Vec<(String, String, String)>,
|
||||||
}
|
}
|
||||||
@@ -272,6 +280,7 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
|||||||
desks.push(Desk {
|
desks.push(Desk {
|
||||||
page: q.id.clone(),
|
page: q.id.clone(),
|
||||||
group: resource.requires_group.clone().or_else(|| q.qualifies.clone()),
|
group: resource.requires_group.clone().or_else(|| q.qualifies.clone()),
|
||||||
|
page_group: q.qualifies.clone(),
|
||||||
moves: resource
|
moves: resource
|
||||||
.transitions
|
.transitions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -284,15 +293,49 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
|||||||
desks
|
desks
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Self-service moves into `bucket` - a visitor holding their own
|
/// Self-service moves in `bucket` - a visitor holding their own
|
||||||
/// record's link, no group involved (unsubscribe).
|
/// record's link, no group involved (unsubscribe, withdraw) - as
|
||||||
fn self_moves(questions: &Questions, bucket: &str) -> Vec<String> {
|
/// (from, to), from the states each may be made from.
|
||||||
|
fn self_moves(questions: &Questions, bucket: &str, initial: &str) -> Vec<(String, String)> {
|
||||||
questions
|
questions
|
||||||
.values()
|
.values()
|
||||||
.flat_map(|q| &q.alternatives)
|
.flat_map(|q| &q.alternatives)
|
||||||
.filter_map(|a| a.self_transition.as_ref())
|
.filter_map(|a| a.self_transition.as_ref())
|
||||||
.filter(|s| s.bucket == bucket)
|
.filter(|s| s.bucket == bucket)
|
||||||
.map(|s| s.to.clone())
|
.flat_map(|s| s.from_states(initial).into_iter().map(|from| (from, s.to.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The moves a clock makes in `schema` (`after:` on a state), as
|
||||||
|
/// (from, to). Nobody presses them, and they still move records.
|
||||||
|
fn clock_moves(schema: &AggregateSchema) -> Vec<(String, String)> {
|
||||||
|
let mut moves: Vec<(String, String)> =
|
||||||
|
schema.after_for_state.iter().map(|(from, after)| (from.clone(), after.to.clone())).collect();
|
||||||
|
moves.sort();
|
||||||
|
moves
|
||||||
|
}
|
||||||
|
|
||||||
|
/// States reachable from `start` along `moves`.
|
||||||
|
fn reachable(start: &str, moves: &[(String, String)]) -> HashSet<String> {
|
||||||
|
let mut reached = HashSet::from([start.to_string()]);
|
||||||
|
let mut queue = VecDeque::from([start.to_string()]);
|
||||||
|
while let Some(state) = queue.pop_front() {
|
||||||
|
for (_, to) in moves.iter().filter(|(from, _)| *from == state) {
|
||||||
|
if reached.insert(to.clone()) {
|
||||||
|
queue.push_back(to.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reached
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who else offers `from -> to` in the bucket, as "page (group)", for
|
||||||
|
/// a finding that has to say where the missing button already is.
|
||||||
|
fn offered_elsewhere(desks: &[Desk], from: &str, to: &str) -> Vec<String> {
|
||||||
|
desks
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.moves.iter().any(|(f, t, _)| f == from && t == to))
|
||||||
|
.map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated")))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,10 +348,16 @@ pub fn check(
|
|||||||
) -> Vec<Finding> {
|
) -> Vec<Finding> {
|
||||||
let mut findings = Vec::new();
|
let mut findings = Vec::new();
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
|
// (bucket, state) a need already reported - stranding on its way
|
||||||
|
// to done, or out of its reach - so the bucket pass does not say
|
||||||
|
// it twice.
|
||||||
|
let mut strands_reported: HashSet<(String, String)> = HashSet::new();
|
||||||
|
let mut unreached_reported: HashSet<(String, String)> = HashSet::new();
|
||||||
for need in &file.needs {
|
for need in &file.needs {
|
||||||
let mut report = |level: Level, message: String| {
|
let mut report = |level: Level, message: String| {
|
||||||
findings.push(Finding {
|
findings.push(Finding {
|
||||||
need: need.id.clone(),
|
need: need.id.clone(),
|
||||||
|
scope: "need",
|
||||||
level: if need.planned || need.paused { Level::Warn } else { level },
|
level: if need.planned || need.paused { Level::Warn } else { level },
|
||||||
message: match (need.paused, need.planned, level == Level::Fail) {
|
message: match (need.paused, need.planned, level == Level::Fail) {
|
||||||
(true, _, true) => format!("paused, not open now: {message}"),
|
(true, _, true) => format!("paused, not open now: {message}"),
|
||||||
@@ -439,23 +488,44 @@ pub fn check(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let selfs = self_moves(questions, &need.lands_in);
|
// Every move that carries this need's records: the buttons on
|
||||||
|
// the desks of handled_by/also_moved_by, the visitor's own
|
||||||
|
// links, and the clocks.
|
||||||
|
let mut moves: Vec<(String, String)> = own
|
||||||
|
.iter()
|
||||||
|
.flat_map(|d| &d.moves)
|
||||||
|
.filter(|(from, to, _)| schema.allowed(from).contains(to))
|
||||||
|
.map(|(from, to, _)| (from.clone(), to.clone()))
|
||||||
|
.collect();
|
||||||
|
moves.extend(self_moves(questions, &need.lands_in, &schema.initial));
|
||||||
|
moves.extend(clock_moves(schema));
|
||||||
let exits = |state: &str| -> Vec<String> {
|
let exits = |state: &str| -> Vec<String> {
|
||||||
let allowed = schema.allowed(state);
|
moves.iter().filter(|(from, _)| from == state).map(|(_, to)| to.clone()).collect()
|
||||||
let mut to: Vec<String> = own
|
};
|
||||||
.iter()
|
let movers = std::iter::once(need.handled_by.as_str())
|
||||||
.flat_map(|d| &d.moves)
|
.chain(need.also_moved_by.iter().map(String::as_str))
|
||||||
.filter(|(from, to, _)| from == state && allowed.contains(to))
|
.map(|g| format!("{g:?}"))
|
||||||
.map(|(_, to, _)| to.clone())
|
.collect::<Vec<_>>()
|
||||||
.collect();
|
.join(" or ");
|
||||||
to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned());
|
// The fix for a missing move: the button on handled_by's desk,
|
||||||
to
|
// or the group whose desk already has it added to also_moved_by.
|
||||||
|
let fix_for = |from: &str, to: &str| -> String {
|
||||||
|
let elsewhere = offered_elsewhere(&desks, from, to);
|
||||||
|
let desk = own.first().map(|d| d.page.as_str()).unwrap_or("their desk");
|
||||||
|
if elsewhere.is_empty() {
|
||||||
|
format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} already offers it - add that group to also_moved_by, or add `{{ from: {from}, to: {to}, label: ... }}` on {desk}",
|
||||||
|
elsewhere.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect();
|
let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect();
|
||||||
// Reachability walks the whole machine (a finished state may
|
// Reachability walks the whole machine (a finished state may
|
||||||
// lead on to another: subscribed, then unsubscribed). Stranding
|
// lead on to another: subscribed, then unsubscribed). Stranding
|
||||||
// only counts on the way to the first finished state - what
|
// only counts on the way to the first finished state here; what
|
||||||
// happens to a handled record afterwards is not this need.
|
// strands a record afterwards is the bucket's finding (below).
|
||||||
let mut reached: HashSet<String> = HashSet::from([schema.initial.clone()]);
|
let mut reached: HashSet<String> = HashSet::from([schema.initial.clone()]);
|
||||||
let mut queue = VecDeque::from([(schema.initial.clone(), false)]);
|
let mut queue = VecDeque::from([(schema.initial.clone(), false)]);
|
||||||
let mut stranded = Vec::new();
|
let mut stranded = Vec::new();
|
||||||
@@ -471,6 +541,26 @@ pub fn check(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The declared edges into `state` from somewhere a record does
|
||||||
|
// get to: the moves that would close the gap.
|
||||||
|
let ways_in = |state: &str| -> Vec<(String, String)> {
|
||||||
|
let mut ways: Vec<(String, String)> = reached
|
||||||
|
.iter()
|
||||||
|
.filter(|from| schema.allowed(from).iter().any(|to| to == state))
|
||||||
|
.map(|from| (from.clone(), state.to_string()))
|
||||||
|
.collect();
|
||||||
|
ways.sort();
|
||||||
|
ways
|
||||||
|
};
|
||||||
|
let missing = |state: &str| -> String {
|
||||||
|
match ways_in(state).first() {
|
||||||
|
Some((from, to)) => format!(" - fix: {}", fix_for(from, to)),
|
||||||
|
None => format!(
|
||||||
|
" - and no state a record reaches has a declared edge into it: add one in aggregates.yaml under {:?}",
|
||||||
|
need.lands_in
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
for state in &need.done_when {
|
for state in &need.done_when {
|
||||||
if !schema.has_state(state) {
|
if !schema.has_state(state) {
|
||||||
report(
|
report(
|
||||||
@@ -478,11 +568,13 @@ pub fn check(
|
|||||||
format!("done_when names {state:?}, which {:?} has no such state", need.lands_in),
|
format!("done_when names {state:?}, which {:?} has no such state", need.lands_in),
|
||||||
);
|
);
|
||||||
} else if !reached.contains(state) {
|
} else if !reached.contains(state) {
|
||||||
|
unreached_reported.insert((need.lands_in.clone(), state.clone()));
|
||||||
report(
|
report(
|
||||||
Level::Fail,
|
Level::Fail,
|
||||||
format!(
|
format!(
|
||||||
"{:?} cannot move a record to {state:?} - no desk of theirs offers the moves",
|
"{:?} cannot move a record to {state:?} - no desk of theirs offers the moves{}",
|
||||||
need.handled_by
|
need.handled_by,
|
||||||
|
missing(state)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -497,11 +589,36 @@ pub fn check(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if !reached.contains(stage) {
|
} else if !reached.contains(stage) {
|
||||||
|
unreached_reported.insert((need.lands_in.clone(), stage.clone()));
|
||||||
report(
|
report(
|
||||||
Level::Fail,
|
Level::Fail,
|
||||||
format!(
|
format!(
|
||||||
"stage {stage:?} exists but {:?} is offered no moves that reach it",
|
"stage {stage:?} exists but {:?} is offered no moves that reach it{}",
|
||||||
need.handled_by
|
need.handled_by,
|
||||||
|
missing(stage)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// In the business's order: each working stage reachable from
|
||||||
|
// the one before it, so a machine where "quoted" cannot follow
|
||||||
|
// "surveyed" does not pass for one that goes surveyed -> quoted.
|
||||||
|
// The finished states that close the list are alternatives
|
||||||
|
// (won, lost), not a sequence, so they are left out.
|
||||||
|
for pair in need.stages.windows(2) {
|
||||||
|
let (before, after) = (&pair[0], &pair[1]);
|
||||||
|
if done.contains(before.as_str()) || done.contains(after.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if reached.contains(before) && reached.contains(after) && !reachable(before, &moves).contains(after) {
|
||||||
|
report(
|
||||||
|
Level::Fail,
|
||||||
|
format!(
|
||||||
|
"stage {after:?} comes after {before:?} for the business, but no move leads on from {before:?} to it{}",
|
||||||
|
match schema.allowed(before).iter().find(|to| reachable(to, &moves).contains(after) || *to == after) {
|
||||||
|
Some(to) => format!(" - fix: {}", fix_for(before, to)),
|
||||||
|
None => format!(" - add an edge from {before:?} toward {after:?} in aggregates.yaml under {:?}", need.lands_in),
|
||||||
|
}
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -515,22 +632,32 @@ pub fn check(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
stranded.sort();
|
stranded.sort();
|
||||||
|
for state in &stranded {
|
||||||
|
strands_reported.insert((need.lands_in.clone(), state.clone()));
|
||||||
|
}
|
||||||
for state in stranded {
|
for state in stranded {
|
||||||
|
let fix = match schema.allowed(&state).first() {
|
||||||
|
Some(to) => format!(" - fix: {}", fix_for(&state, to)),
|
||||||
|
None => format!(
|
||||||
|
" - and {:?} declares no way out of it: add {state:?} to done_when if it is finished, or an edge out in aggregates.yaml",
|
||||||
|
need.lands_in
|
||||||
|
),
|
||||||
|
};
|
||||||
report(
|
report(
|
||||||
Level::Fail,
|
Level::Fail,
|
||||||
format!(
|
format!("records strand in {state:?}: {movers} is offered no move out of it{fix}"),
|
||||||
"records strand in {state:?}: {:?} is offered no move out of it",
|
|
||||||
need.handled_by
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findings.extend(operations(file, questions, aggregates, &strands_reported, &unreached_reported));
|
||||||
|
|
||||||
let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect();
|
let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect();
|
||||||
for persona in &file.personas {
|
for persona in &file.personas {
|
||||||
if !need_ids.contains(persona.need.as_str()) {
|
if !need_ids.contains(persona.need.as_str()) {
|
||||||
findings.push(Finding {
|
findings.push(Finding {
|
||||||
need: persona.need.clone(),
|
need: persona.need.clone(),
|
||||||
|
scope: "need",
|
||||||
level: Level::Fail,
|
level: Level::Fail,
|
||||||
message: format!("persona {:?} points at a need that is not declared", persona.id),
|
message: format!("persona {:?} points at a need that is not declared", persona.id),
|
||||||
});
|
});
|
||||||
@@ -539,6 +666,188 @@ pub fn check(
|
|||||||
findings
|
findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What must hold for every record in a bucket some need lands in,
|
||||||
|
/// whichever need brought it: every state is somewhere a record can
|
||||||
|
/// get to, no state a record gets to holds it with no way on (before
|
||||||
|
/// done or after), every declared move is a button someone has, every
|
||||||
|
/// desk can be seen by whoever its buttons are for, and every mail
|
||||||
|
/// goes out from a state records reach, saying only what the record
|
||||||
|
/// holds.
|
||||||
|
fn operations(
|
||||||
|
file: &NeedsFile,
|
||||||
|
questions: &Questions,
|
||||||
|
aggregates: &HashMap<String, AggregateSchema>,
|
||||||
|
strands_reported: &HashSet<(String, String)>,
|
||||||
|
unreached_reported: &HashSet<(String, String)>,
|
||||||
|
) -> Vec<Finding> {
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
let mut buckets: Vec<&str> = file.needs.iter().map(|n| n.lands_in.as_str()).collect();
|
||||||
|
buckets.sort();
|
||||||
|
buckets.dedup();
|
||||||
|
for bucket in buckets {
|
||||||
|
let Some(schema) = aggregates.get(bucket) else { continue };
|
||||||
|
let needs: Vec<&Need> = file.needs.iter().filter(|n| n.lands_in == bucket).collect();
|
||||||
|
// Only open needs make a gap fail; a bucket that only planned
|
||||||
|
// or paused needs use reports it as a warning.
|
||||||
|
let open = needs.iter().any(|n| !n.planned && !n.paused);
|
||||||
|
let mut report = |level: Level, message: String| {
|
||||||
|
findings.push(Finding {
|
||||||
|
need: bucket.to_string(),
|
||||||
|
scope: "bucket",
|
||||||
|
level: if open { level } else { Level::Warn },
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let desks = desks_over(questions, bucket);
|
||||||
|
let mut moves: Vec<(String, String)> = desks
|
||||||
|
.iter()
|
||||||
|
.flat_map(|d| &d.moves)
|
||||||
|
.filter(|(from, to, _)| schema.allowed(from).contains(to))
|
||||||
|
.map(|(from, to, _)| (from.clone(), to.clone()))
|
||||||
|
.collect();
|
||||||
|
moves.extend(self_moves(questions, bucket, &schema.initial));
|
||||||
|
moves.extend(clock_moves(schema));
|
||||||
|
let reached = reachable(&schema.initial, &moves);
|
||||||
|
let offered = |from: &str, to: &str| moves.iter().any(|(f, t)| f == from && t == to);
|
||||||
|
// Where a missing button goes: the desk of the group answerable
|
||||||
|
// for the bucket's first need.
|
||||||
|
let home = needs
|
||||||
|
.iter()
|
||||||
|
.find_map(|n| desks.iter().find(|d| d.group.as_deref() == Some(n.handled_by.as_str())))
|
||||||
|
.map(|d| d.page.clone())
|
||||||
|
.unwrap_or_else(|| "the handling group's desk".to_string());
|
||||||
|
let button = |from: &str, to: &str| format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}");
|
||||||
|
|
||||||
|
let mut states: Vec<&String> = schema.event_for_state.keys().collect();
|
||||||
|
states.sort();
|
||||||
|
for state in &states {
|
||||||
|
if reached.contains(*state) || unreached_reported.contains(&(bucket.to_string(), (*state).clone())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut ways: Vec<&String> = reached.iter().filter(|from| schema.allowed(from).contains(state)).collect();
|
||||||
|
ways.sort();
|
||||||
|
report(
|
||||||
|
Level::Fail,
|
||||||
|
format!(
|
||||||
|
"state {state:?} is never reached: no desk, link or clock moves a record there - fix: {}",
|
||||||
|
match ways.first() {
|
||||||
|
Some(from) => button(from, state),
|
||||||
|
None => "declare an edge into it from a state records reach in aggregates.yaml, or remove it".to_string(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for state in states.iter().filter(|s| reached.contains(**s)) {
|
||||||
|
let out = schema.allowed(state);
|
||||||
|
if out.is_empty() {
|
||||||
|
continue; // a final state: nothing should move it
|
||||||
|
}
|
||||||
|
let offered_out: Vec<&String> = out.iter().filter(|to| offered(state, to)).collect();
|
||||||
|
if offered_out.is_empty() {
|
||||||
|
if !strands_reported.contains(&(bucket.to_string(), (*state).clone())) {
|
||||||
|
report(
|
||||||
|
Level::Fail,
|
||||||
|
format!(
|
||||||
|
"records strand in {state:?}: {bucket:?} declares moves on to {} but no desk, link or clock offers one - fix: {}",
|
||||||
|
out.join(", "),
|
||||||
|
button(state, &out[0])
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for to in out.iter().filter(|to| !offered(state, to)) {
|
||||||
|
report(
|
||||||
|
Level::Warn,
|
||||||
|
format!(
|
||||||
|
"the move {state:?} -> {to:?} is declared but no desk, link or clock offers it - {}, or drop the edge from aggregates.yaml",
|
||||||
|
button(state, to)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for desk in &desks {
|
||||||
|
if let (Some(page_group), Some(group)) = (&desk.page_group, &desk.group) {
|
||||||
|
if page_group != group {
|
||||||
|
report(
|
||||||
|
Level::Warn,
|
||||||
|
format!(
|
||||||
|
"the desk on {} is gated twice: the page qualifies {page_group:?}, its list requires {group:?} - only members of both see the buttons; make them the same group",
|
||||||
|
desk.page
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The fields a record of this bucket holds: everything asked on
|
||||||
|
// a form that records into it.
|
||||||
|
let fields: Vec<(&str, &str)> = questions
|
||||||
|
.values()
|
||||||
|
.flat_map(|q| &q.alternatives)
|
||||||
|
.filter(|a| a.record_as.as_deref() == Some(bucket))
|
||||||
|
.flat_map(|a| &a.features)
|
||||||
|
.flat_map(|f| &f.requirements)
|
||||||
|
.map(|r| (r.name.as_str(), r.kind.as_str()))
|
||||||
|
.collect();
|
||||||
|
let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> = schema.mail_for_state.iter().collect();
|
||||||
|
mail_states.sort_by(|a, b| a.0.cmp(b.0));
|
||||||
|
for (state, mail) in mail_states {
|
||||||
|
if !reached.contains(state) {
|
||||||
|
report(Level::Warn, format!("the mail on entering {state:?} never goes out: no record reaches {state:?}"));
|
||||||
|
}
|
||||||
|
let mut unknown: Vec<String> = Vec::new();
|
||||||
|
for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) {
|
||||||
|
let known = match key.split_once('.') {
|
||||||
|
Some((parent, _)) => fields.iter().any(|(name, kind)| *name == parent && *kind == "record"),
|
||||||
|
None => MAIL_BUILTINS.contains(&key.as_str()) || fields.iter().any(|(name, _)| *name == key),
|
||||||
|
};
|
||||||
|
if !known && !unknown.contains(&key) {
|
||||||
|
unknown.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !unknown.is_empty() {
|
||||||
|
let mut names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
|
||||||
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
|
report(
|
||||||
|
Level::Fail,
|
||||||
|
format!(
|
||||||
|
"the mail on entering {state:?} uses {}, which no form recording into {bucket:?} collects - it would go out blank there; the forms collect: {}",
|
||||||
|
unknown.iter().map(|k| format!("{{{k}}}")).collect::<Vec<_>>().join(", "),
|
||||||
|
if names.is_empty() { "nothing".to_string() } else { names.join(", ") }
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
findings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the mailer fills in on every state mail, whatever the record
|
||||||
|
/// holds (`mail::vars_for`, `mail::site_vars`, `mail::record_links`).
|
||||||
|
const MAIL_BUILTINS: &[&str] = &["site", "site_name", "chain", "email", "bucket", "state", "link", "self_link", "desk_link"];
|
||||||
|
|
||||||
|
/// The `{key}`s in a mail template, read the way `mail::render` reads
|
||||||
|
/// them: letters, digits, `_`, `-` and `.`, closed by `}`.
|
||||||
|
fn placeholders(template: &str) -> Vec<String> {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
let mut rest = template;
|
||||||
|
while let Some(start) = rest.find('{') {
|
||||||
|
let after = &rest[start + 1..];
|
||||||
|
let len = after
|
||||||
|
.char_indices()
|
||||||
|
.take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
|
||||||
|
.last()
|
||||||
|
.map(|(i, c)| i + c.len_utf8())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if len > 0 && after[len..].starts_with('}') {
|
||||||
|
keys.push(after[..len].to_string());
|
||||||
|
}
|
||||||
|
rest = after;
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything a simulation of the business needs, resolved: each
|
/// Everything a simulation of the business needs, resolved: each
|
||||||
/// need's path with the choice on every page and the form that gets
|
/// need's path with the choice on every page and the form that gets
|
||||||
/// filled, each bucket's state machine with the buttons every desk
|
/// filled, each bucket's state machine with the buttons every desk
|
||||||
@@ -611,6 +920,11 @@ pub fn sim_model(
|
|||||||
"steps": steps,
|
"steps": steps,
|
||||||
}));
|
}));
|
||||||
if let Some(schema) = aggregates.get(&need.lands_in) {
|
if let Some(schema) = aggregates.get(&need.lands_in) {
|
||||||
|
// The simulator's shape: the states a visitor's own link
|
||||||
|
// moves to (it does not model where from).
|
||||||
|
let mut self_move_targets: Vec<String> =
|
||||||
|
self_moves(questions, &need.lands_in, &schema.initial).into_iter().map(|(_, to)| to).collect();
|
||||||
|
self_move_targets.dedup();
|
||||||
buckets.entry(need.lands_in.clone()).or_insert_with(|| {
|
buckets.entry(need.lands_in.clone()).or_insert_with(|| {
|
||||||
json!({
|
json!({
|
||||||
"initial": schema.initial,
|
"initial": schema.initial,
|
||||||
@@ -623,7 +937,7 @@ pub fn sim_model(
|
|||||||
"from": from, "to": to, "label": label,
|
"from": from, "to": to, "label": label,
|
||||||
})).collect::<Vec<_>>(),
|
})).collect::<Vec<_>>(),
|
||||||
})).collect::<Vec<_>>(),
|
})).collect::<Vec<_>>(),
|
||||||
"self_moves": self_moves(questions, &need.lands_in),
|
"self_moves": self_move_targets,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -895,8 +1209,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
fails(&findings),
|
fails(&findings),
|
||||||
vec![
|
vec![
|
||||||
"\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves",
|
"\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk",
|
||||||
"records strand in \"quoted\": \"owners\" is offered no move out of it",
|
"records strand in \"quoted\": \"owners\" is offered no move out of it - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1037,4 +1351,102 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]);
|
assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fixture site with its aggregates replaced, for the checks
|
||||||
|
/// that are about the state machine rather than the pages.
|
||||||
|
fn site_with(desk_moves: &str, projects: &str) -> (Questions, HashMap<String, AggregateSchema>) {
|
||||||
|
let (questions, _) = site(desk_moves);
|
||||||
|
let aggregates = parse_aggregates_yaml(&format!(
|
||||||
|
"aggregates:\n - bucket: projects\n{projects} - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: {{ event: subscribed }}\n transitions: {{}}\n"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
(questions, aggregates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warns(findings: &[Finding]) -> Vec<&str> {
|
||||||
|
findings.iter().filter(|f| f.level == Level::Warn).map(|f| f.message.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clock_is_a_move() {
|
||||||
|
// Nobody closes a stale quote; six weeks do. That is not a strand.
|
||||||
|
let (questions, aggregates) = site_with(
|
||||||
|
" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n",
|
||||||
|
" initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted, after: { days: 42, to: lost } }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted]\n quoted: [won, lost]\n",
|
||||||
|
);
|
||||||
|
let findings = check(&needs(""), &questions, &aggregates);
|
||||||
|
assert!(fails(&findings).is_empty(), "{findings:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_state_nobody_reaches_and_a_strand_after_done_are_the_buckets() {
|
||||||
|
// "archived" follows "won" but no desk offers it; "parked" has
|
||||||
|
// no way in at all.
|
||||||
|
let (questions, aggregates) = site_with(
|
||||||
|
FULL_DESK,
|
||||||
|
" initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n archived: { event: archived }\n done: { event: done }\n parked: { event: parked }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n won: [archived]\n archived: [done]\n parked: [open]\n",
|
||||||
|
);
|
||||||
|
let findings = check(&needs(""), &questions, &aggregates);
|
||||||
|
let bucket: Vec<&Finding> = findings.iter().filter(|f| f.scope == "bucket").collect();
|
||||||
|
assert!(bucket.iter().all(|f| f.need == "projects"), "{bucket:?}");
|
||||||
|
assert_eq!(
|
||||||
|
fails(&findings),
|
||||||
|
vec![
|
||||||
|
"state \"archived\" is never reached: no desk, link or clock moves a record there - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk",
|
||||||
|
"state \"done\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it",
|
||||||
|
"state \"parked\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it",
|
||||||
|
"records strand in \"won\": \"projects\" declares moves on to archived but no desk, link or clock offers one - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edge_nobody_offers_is_a_warning_when_there_is_another_way_on() {
|
||||||
|
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n");
|
||||||
|
let findings = check(&needs(""), &questions, &aggregates);
|
||||||
|
assert!(fails(&findings).is_empty(), "{findings:?}");
|
||||||
|
assert_eq!(
|
||||||
|
warns(&findings),
|
||||||
|
vec!["the move \"open\" -> \"lost\" is declared but no desk, link or clock offers it - add `{ from: open, to: lost, label: ... }` to the transitions on /desk, or drop the edge from aggregates.yaml"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_stages_must_follow_each_other() {
|
||||||
|
// The business surveys, then quotes; this machine can only
|
||||||
|
// quote straight from open, and surveying leads nowhere.
|
||||||
|
let (questions, aggregates) = site_with(
|
||||||
|
" - { to: quoted, label: Quote }\n - { to: surveyed, label: Survey }\n - { from: surveyed, to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n",
|
||||||
|
" initial: open\n states:\n open: { event: submitted }\n surveyed: { event: surveyed }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [surveyed, quoted]\n surveyed: [lost]\n quoted: [won, lost]\n",
|
||||||
|
);
|
||||||
|
let findings = check(&needs(" stages: [open, surveyed, quoted, won, lost]\n"), &questions, &aggregates);
|
||||||
|
assert_eq!(
|
||||||
|
fails(&findings),
|
||||||
|
vec!["stage \"quoted\" comes after \"surveyed\" for the business, but no move leads on from \"surveyed\" to it - add an edge from \"surveyed\" toward \"quoted\" in aggregates.yaml under \"projects\""]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mail_may_only_say_what_the_record_holds() {
|
||||||
|
let (questions, aggregates) = site_with(
|
||||||
|
FULL_DESK,
|
||||||
|
" initial: open\n states:\n open: { event: submitted, mail: { to: submitter, subject: 'Got it, {email}', body: 'We read {brief} and will reply at {site}. {refnr} {link}' } }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n",
|
||||||
|
);
|
||||||
|
let findings = check(&needs(""), &questions, &aggregates);
|
||||||
|
assert_eq!(
|
||||||
|
fails(&findings),
|
||||||
|
vec!["the mail on entering \"open\" uses {refnr}, which no form recording into \"projects\" collects - it would go out blank there; the forms collect: brief, budget, email"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_desk_gated_twice_is_seen_only_by_members_of_both() {
|
||||||
|
let (mut questions, aggregates) = site(FULL_DESK);
|
||||||
|
questions.get_mut("/desk").unwrap().qualifies = Some("staff".into());
|
||||||
|
let findings = check(&needs(""), &questions, &aggregates);
|
||||||
|
assert!(
|
||||||
|
warns(&findings).contains(&"the desk on /desk is gated twice: the page qualifies \"staff\", its list requires \"owners\" - only members of both see the buttons; make them the same group"),
|
||||||
|
"{findings:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-5
@@ -251,6 +251,80 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
|
|||||||
vars
|
vars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What every mail portal sends may say, whatever made it - a state
|
||||||
|
/// mail, an invite, a suggestion's answer: `{site}` (the instance's
|
||||||
|
/// public URL) and `{site_name}` (site.yaml's title). One place, so a
|
||||||
|
/// template written for one kind of mail reads the same in another
|
||||||
|
/// (docs/mail.md, Variables).
|
||||||
|
pub fn site_vars(site: &crate::content::SiteConfig) -> serde_json::Map<String, serde_json::Value> {
|
||||||
|
let mut vars = serde_json::Map::new();
|
||||||
|
vars.insert("site".into(), public_url().into());
|
||||||
|
vars.insert("site_name".into(), site_name(site).into());
|
||||||
|
vars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a mail about a record can send its reader (docs/mail.md):
|
||||||
|
///
|
||||||
|
/// - `{self_link}`: the record's own page for the person it belongs
|
||||||
|
/// to - a page offering a self-service move on this bucket from the
|
||||||
|
/// state the record is in, with the `?chain=&email=` that
|
||||||
|
/// lets them act without signing in;
|
||||||
|
/// - `{desk_link}`: the desk that moves the record - the page whose
|
||||||
|
/// list over the bucket carries transitions, else one that reads it;
|
||||||
|
/// - `{link}`: `{self_link}` in a mail to the submitter, `{desk_link}`
|
||||||
|
/// in one to anyone else, and the site itself when there is neither.
|
||||||
|
///
|
||||||
|
/// Empty strings, never missing keys, for a link the content has no
|
||||||
|
/// page for: a template naming one renders blank there rather than
|
||||||
|
/// with a broken URL.
|
||||||
|
pub fn record_links(
|
||||||
|
questions: &std::collections::HashMap<String, crate::content::Question>,
|
||||||
|
initial: &str,
|
||||||
|
bucket: &str,
|
||||||
|
id: &str,
|
||||||
|
state: &str,
|
||||||
|
email: &str,
|
||||||
|
to_submitter: bool,
|
||||||
|
) -> serde_json::Map<String, serde_json::Value> {
|
||||||
|
let mut pages: Vec<&crate::content::Question> = questions.values().collect();
|
||||||
|
pages.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
// Only a move the engine would accept from where the record is:
|
||||||
|
// a link to a button that refuses is worse than none.
|
||||||
|
let self_page = pages.iter().find(|q| {
|
||||||
|
q.alternatives
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| a.self_transition.as_ref())
|
||||||
|
.any(|st| st.bucket == bucket && st.from_states(initial).iter().any(|f| f == state))
|
||||||
|
});
|
||||||
|
let reads = |q: &&crate::content::Question, moving: bool| {
|
||||||
|
q.alternatives
|
||||||
|
.iter()
|
||||||
|
.flat_map(|a| &a.features)
|
||||||
|
.filter_map(|f| f.resource.as_ref())
|
||||||
|
.any(|r| r.bucket() == Some(bucket) && (!moving || !r.transitions.is_empty()))
|
||||||
|
};
|
||||||
|
let desk_page = pages.iter().find(|q| reads(q, true)).or_else(|| pages.iter().find(|q| reads(q, false)));
|
||||||
|
let site = public_url();
|
||||||
|
let self_link = match self_page {
|
||||||
|
Some(q) if email.contains('@') => {
|
||||||
|
let email: String = url::form_urlencoded::byte_serialize(email.as_bytes()).collect();
|
||||||
|
format!("{site}{}?chain={id}&email={email}", q.id)
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let desk_link = desk_page.map(|q| format!("{site}{}", q.id)).unwrap_or_default();
|
||||||
|
let link = match (to_submitter, self_link.is_empty(), desk_link.is_empty()) {
|
||||||
|
(true, false, _) => self_link.clone(),
|
||||||
|
(false, _, false) => desk_link.clone(),
|
||||||
|
_ => site,
|
||||||
|
};
|
||||||
|
let mut vars = serde_json::Map::new();
|
||||||
|
vars.insert("self_link".into(), self_link.into());
|
||||||
|
vars.insert("desk_link".into(), desk_link.into());
|
||||||
|
vars.insert("link".into(), link.into());
|
||||||
|
vars
|
||||||
|
}
|
||||||
|
|
||||||
/// The record field a `to` reaches through, when it is neither
|
/// The record field a `to` reaches through, when it is neither
|
||||||
/// `submitter` nor an address: `{notice.email}` gives `notice`.
|
/// `submitter` nor an address: `{notice.email}` gives `notice`.
|
||||||
pub fn parent_of(to: &str) -> Option<&str> {
|
pub fn parent_of(to: &str) -> Option<&str> {
|
||||||
@@ -325,6 +399,13 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
|||||||
};
|
};
|
||||||
let mut vars = vars_for(bucket, id, to_state, responses);
|
let mut vars = vars_for(bucket, id, to_state, responses);
|
||||||
parent_vars(state, bucket, responses, &mut vars).await;
|
parent_vars(state, bucket, responses, &mut vars).await;
|
||||||
|
vars.extend(site_vars(site));
|
||||||
|
{
|
||||||
|
let email = vars.get("email").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||||
|
let questions = state.questions.load();
|
||||||
|
let initial = state.aggregates.load().at(storyline).get(bucket).map(|s| s.initial.clone()).unwrap_or_default();
|
||||||
|
vars.extend(record_links(questions.at(storyline), &initial, bucket, id, to_state, &email, spec.to_submitter()));
|
||||||
|
}
|
||||||
let to = if spec.to_submitter() {
|
let to = if spec.to_submitter() {
|
||||||
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
|
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
|
||||||
Some(e) => e.to_string(),
|
Some(e) => e.to_string(),
|
||||||
@@ -427,9 +508,8 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
|
|||||||
vars.insert("name".into(), invited.display_name.clone().into());
|
vars.insert("name".into(), invited.display_name.clone().into());
|
||||||
vars.insert("username".into(), invited.username.clone().into());
|
vars.insert("username".into(), invited.username.clone().into());
|
||||||
vars.insert("questions".into(), list.into());
|
vars.insert("questions".into(), list.into());
|
||||||
|
vars.extend(site_vars(&site));
|
||||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||||
vars.insert("site".into(), public_url().into());
|
|
||||||
vars.insert("site_name".into(), site_name(&site).into());
|
|
||||||
vars.insert("expires".into(), expires.into());
|
vars.insert("expires".into(), expires.into());
|
||||||
let text = render(&body, &vars);
|
let text = render(&body, &vars);
|
||||||
let mail = Outgoing {
|
let mail = Outgoing {
|
||||||
@@ -501,7 +581,7 @@ async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
|
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
|
||||||
let mut vars = serde_json::Map::new();
|
let mut vars = site_vars(&site);
|
||||||
vars.insert("name".into(), field("name").into());
|
vars.insert("name".into(), field("name").into());
|
||||||
vars.insert("question".into(), field("question").into());
|
vars.insert("question".into(), field("question").into());
|
||||||
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
|
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
|
||||||
@@ -617,9 +697,8 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
|||||||
vars.insert("username".into(), invited.username.clone().into());
|
vars.insert("username".into(), invited.username.clone().into());
|
||||||
vars.insert("email".into(), invited.email.clone().into());
|
vars.insert("email".into(), invited.email.clone().into());
|
||||||
vars.insert("group".into(), invited.group.clone().into());
|
vars.insert("group".into(), invited.group.clone().into());
|
||||||
|
vars.extend(site_vars(&site));
|
||||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||||
vars.insert("site".into(), public_url().into());
|
|
||||||
vars.insert("site_name".into(), site_name(&site).into());
|
|
||||||
vars.insert("expires".into(), expires.into());
|
vars.insert("expires".into(), expires.into());
|
||||||
let text = render(&body, &vars);
|
let text = render(&body, &vars);
|
||||||
let mail = Outgoing {
|
let mail = Outgoing {
|
||||||
@@ -640,6 +719,30 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_mail_links_its_reader_to_where_they_act() {
|
||||||
|
let files = vec![
|
||||||
|
("index.yaml".to_string(), "name: Meld fra\nalternatives:\n - name: Feil\n action: /takk\n record_as: feil\n features:\n - name: Feil\n requirements:\n - { name: email, type: email }\n".to_string()),
|
||||||
|
("takk.yaml".to_string(), "name: Takk\nfollowup: true\n".to_string()),
|
||||||
|
("trekk.yaml".to_string(), "name: Trekk saken\nalternatives:\n - name: Trekk\n self_transition: { bucket: feil, to: trukket, label: Trekk, from: [open, befaring] }\n".to_string()),
|
||||||
|
("styret/index.yaml".to_string(), "name: Styret\nqualifies: styret\nalternatives:\n - name: Feil\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: feil }\n requires_group: styret\n transitions:\n - { to: befaring, label: Befaring }\n".to_string()),
|
||||||
|
];
|
||||||
|
let questions = crate::content::build_questions(&files).unwrap();
|
||||||
|
let site = public_url();
|
||||||
|
let to_tenant = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", true);
|
||||||
|
assert_eq!(to_tenant["self_link"], format!("{site}/trekk?chain=abc&email=kari%40x.no"));
|
||||||
|
assert_eq!(to_tenant["desk_link"], format!("{site}/styret"));
|
||||||
|
assert_eq!(to_tenant["link"], to_tenant["self_link"]);
|
||||||
|
let to_board = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", false);
|
||||||
|
assert_eq!(to_board["link"], to_board["desk_link"]);
|
||||||
|
// Past where the self-service move can be made: the person has
|
||||||
|
// no page of their own, so `{link}` is the site.
|
||||||
|
let after = record_links(&questions, "open", "feil", "abc", "utbedret", "kari@x.no", true);
|
||||||
|
assert_eq!(after["self_link"], "");
|
||||||
|
assert_eq!(after["link"], site);
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -743,6 +846,7 @@ mod tests {
|
|||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ mod tests {
|
|||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -722,6 +722,7 @@ mod tests {
|
|||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,11 @@ async fn fetch_resource_value(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !resource.transitions.is_empty() {
|
||||||
|
if let Some(schema) = state.aggregates.load().at(0).get(bucket) {
|
||||||
|
items = cap_finished(items, schema, finished_wanted(params));
|
||||||
|
}
|
||||||
|
}
|
||||||
serde_json::Value::Array(items)
|
serde_json::Value::Array(items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +353,41 @@ async fn fetch_resource_value(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many finished records a desk listing should show: three, or
|
||||||
|
/// what the page asked for with `__finished` after "show older".
|
||||||
|
pub const FINISHED_PARAM: &str = "__finished";
|
||||||
|
pub const FINISHED_SHOWN: usize = 3;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn finished_wanted(params: &std::collections::HashMap<String, String>) -> usize {
|
||||||
|
params.get(FINISHED_PARAM).and_then(|n| n.parse().ok()).unwrap_or(FINISHED_SHOWN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A desk is for the cases still moving. Every record still open stays;
|
||||||
|
/// of those in a final state (no move out), only the `shown` most
|
||||||
|
/// recently decided, plus one more so the page knows there are older
|
||||||
|
/// ones to offer. Each finished record is marked, so the page can tell
|
||||||
|
/// them apart without knowing the state machine.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub(crate) fn cap_finished(items: Vec<serde_json::Value>, schema: &crate::aggregates::AggregateSchema, shown: usize) -> Vec<serde_json::Value> {
|
||||||
|
let is_final = |v: &serde_json::Value| {
|
||||||
|
v.get("state").and_then(|s| s.as_str()).is_some_and(|s| schema.has_state(s) && schema.allowed(s).is_empty())
|
||||||
|
};
|
||||||
|
let when = |v: &serde_json::Value| {
|
||||||
|
v.get("decided_ms").and_then(|d| d.as_i64()).or_else(|| v.get("submitted_ms").and_then(|d| d.as_i64())).unwrap_or(0)
|
||||||
|
};
|
||||||
|
let (mut finished, mut open): (Vec<_>, Vec<_>) = items.into_iter().partition(is_final);
|
||||||
|
finished.sort_by_key(|v| std::cmp::Reverse(when(v)));
|
||||||
|
finished.truncate(shown + 1);
|
||||||
|
for v in &mut finished {
|
||||||
|
if let Some(obj) = v.as_object_mut() {
|
||||||
|
obj.insert("finished".into(), true.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open.extend(finished);
|
||||||
|
open
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches JSON from a path on the same Gitea instance content is
|
/// Fetches JSON from a path on the same Gitea instance content is
|
||||||
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
|
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
|
||||||
/// set - confirmed live against `project.uhhm.no` that at least the
|
/// set - confirmed live against `project.uhhm.no` that at least the
|
||||||
@@ -758,3 +798,32 @@ mod tests {
|
|||||||
assert_eq!(out, serde_json::json!(["id: /x\n"]));
|
assert_eq!(out, serde_json::json!(["id: /x\n"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "ssr"))]
|
||||||
|
mod finished_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_desk_keeps_every_open_case_and_the_newest_finished_ones() {
|
||||||
|
let schema = crate::aggregates::parse_aggregates_yaml(
|
||||||
|
"aggregates:\n - bucket: leads\n initial: open\n states:\n open: { event: in }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [won, lost]\n",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.remove("leads")
|
||||||
|
.unwrap();
|
||||||
|
let rec = |id: &str, state: &str, decided: i64| serde_json::json!({ "id": id, "state": state, "submitted_ms": 0, "decided_ms": decided });
|
||||||
|
let mut items = vec![rec("o1", "open", 0), rec("o2", "open", 0)];
|
||||||
|
items.extend((1..=6).map(|n| rec(&format!("f{n}"), if n % 2 == 0 { "won" } else { "lost" }, n)));
|
||||||
|
let ids = |v: &[serde_json::Value]| v.iter().map(|x| x["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let shown = cap_finished(items.clone(), &schema, 3);
|
||||||
|
// Both open cases, then the three newest finished and one more
|
||||||
|
// to tell the page there are older ones.
|
||||||
|
assert_eq!(ids(&shown), ["o1", "o2", "f6", "f5", "f4", "f3"]);
|
||||||
|
assert!(shown[2..].iter().all(|v| v["finished"] == true));
|
||||||
|
assert!(shown[..2].iter().all(|v| v.get("finished").is_none()));
|
||||||
|
|
||||||
|
// Asking for all of them: no extra, so no "show older".
|
||||||
|
assert_eq!(cap_finished(items, &schema, 6).len(), 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -190,7 +190,16 @@ not done. Its messages are exact:
|
|||||||
- `no desk gated on "g" reads "b"`: add the desk.
|
- `no desk gated on "g" reads "b"`: add the desk.
|
||||||
- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk
|
- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk
|
||||||
is missing a transition the state machine allows, or the state
|
is missing a transition the state machine allows, or the state
|
||||||
machine is missing the edge.
|
machine is missing the edge. The message ends in `fix:` with the
|
||||||
|
exact `{ from, to, label }` and the desk page to put it on - do that.
|
||||||
|
- `state "s" is never reached`: nothing moves a record into it. Offer
|
||||||
|
the move it names, or remove the state.
|
||||||
|
- `stage "b" comes after "a" ... but no move leads on`: the machine
|
||||||
|
cannot go the way the business works; add the edge it names.
|
||||||
|
- `the mail on entering "s" uses {x}`: the mail names a field no form
|
||||||
|
collects. Use one of the fields it lists, or collect `x`.
|
||||||
|
- WARN `the move "a" -> "b" is declared but no desk ... offers it`:
|
||||||
|
offer it, or drop the edge - every edge should be a button.
|
||||||
- `action ... does not match any declared question id`: the page does
|
- `action ... does not match any declared question id`: the page does
|
||||||
not exist.
|
not exist.
|
||||||
- `entry page ... names no one responsible`: add `responsible`.
|
- `entry page ... names no one responsible`: add `responsible`.
|
||||||
|
|||||||
@@ -1280,6 +1280,25 @@ textarea:focus {
|
|||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* After the newest finished cases: the older ones, on request. Quiet,
|
||||||
|
since the desk is for what is still moving. */
|
||||||
|
.show-older {
|
||||||
|
justify-self: start;
|
||||||
|
background: none;
|
||||||
|
border: 0.06rem solid var(--line);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-older:hover {
|
||||||
|
color: inherit;
|
||||||
|
border-color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.answer-row {
|
.answer-row {
|
||||||
border: 0.06rem solid var(--line);
|
border: 0.06rem solid var(--line);
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user