One record answers another, from a list
Test / test (pull_request) Successful in 56s

Three small things that together make a board work: a link on every
listed record to a page about it (ResourceSpec.link, {id} and {field}
in the address); a state mail whose `to` is a field of the record this
one belongs to ({notice.email}), with that record's fields in the
text, and a lint that the form carries the record field it reaches
through; and value: "{user.email}" filled from the session and hidden,
so someone the site knows is not asked who they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 06:47:36 +02:00
co-authored by Claude Fable 5.1
parent 470338b969
commit fc998c8592
9 changed files with 239 additions and 11 deletions
+1
View File
@@ -6,6 +6,7 @@ content repos pin the tag they run (see the end of this file).
## Unreleased ## Unreleased
- One record answers another, from a list. `link` on a resource puts a link on every listed record to a page about it (`{id}`, `{field}`), a state mail's `to` may be a field of the record this one belongs to (`{notice.email}`, and `{notice.field}` in the text), and `value: "{user.email}"` fills a field from the session and hides it, so a signed-in person is not asked who they are. Together they make a board: a notice is listed with "answer this", the answer page's form carries the notice's id, and the mail goes to whoever posted it. The lint requires the record field the mail reaches through.
- The way down the tree is the line at the foot of the page and nothing else. The arrow fixed at the left edge, which 0.5.3 added beside it, is gone: it followed the reader down every page to offer something the foot already does. - The way down the tree is the line at the foot of the page and nothing else. The arrow fixed at the left edge, which 0.5.3 added beside it, is gone: it followed the reader down every page to offer something the foot already does.
## 0.5.4 (2026-09-29) ## 0.5.4 (2026-09-29)
+18
View File
@@ -136,6 +136,10 @@ is a feature wasted: each one says something true about the choice.
value: "{key}" # a preset; a dynamic page's segment fills it value: "{key}" # a preset; a dynamic page's segment fills it
``` ```
`value: "{user.email}"` or `"{user.name}"` is the signed-in person's
own, and a field it fills is carried rather than asked: someone the
site knows is not asked who they are. A visitor gets the field, empty.
`type` is one of: `type` is one of:
| type | what it is | | type | what it is |
@@ -192,6 +196,19 @@ The other half is a listing that asks for the children:
where: { field: case, equals: "{key}" } where: { field: case, equals: "{key}" }
``` ```
And the way there from a list: `link` puts a link on every listed
record to a page about it, with `{id}` for the record's id and
`{field}` for any of its fields. A notice on a public board links to
`/oppslag/{id}`, the dynamic page `/oppslag/[key]` whose form carries
the notice's id back in with `value: "{key}"`:
```yaml
resource:
source: { kind: kv, bucket: oppslag }
public: true
link: { to: "/oppslag/{id}", label: Jeg kan låne ut eller låne denne }
```
## A resource ## A resource
Live data, declared by content and read by the server. What a resource Live data, declared by content and read by the server. What a resource
@@ -206,6 +223,7 @@ reads is never a client-supplied parameter.
where: { field: case, equals: "{key}" } where: { field: case, equals: "{key}" }
jq: ".[] | {name, url: .html_url}" jq: ".[] | {name, url: .html_url}"
empty: Nothing decided yet. empty: Nothing decided yet.
link: { to: "/oppslag/{id}", label: Answer this } # a page per record
transitions: # the desk's buttons transitions: # the desk's buttons
- { from: complete, to: on_hearing, label: Send it out } - { from: complete, to: on_hearing, label: Send it out }
``` ```
+7
View File
@@ -62,6 +62,13 @@ 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`.
A record that belongs to another (a `type: record` field, say
`notice`) can write to that record's people: `to: "{notice.email}"`
goes to whoever posted the notice, and `{notice.hva}` in the body is
what they posted. The lint requires every form filling the bucket to
carry that field. This is how an answer to a notice on a board reaches
the neighbour who put it there, without anyone in between.
Portal renders it and publishes on `portal.mail.send`. gdo delivers Portal renders it and publishes on `portal.mail.send`. gdo delivers
it, and a reply comes back on `portal.mail.received` as a note beside it, and a reply comes back on `portal.mail.received` as a note beside
the record. A note never moves a case. the record. A note never moves a case.
+9 -5
View File
@@ -55,11 +55,15 @@ pub struct AggregateSchema {
/// A mail to send when a record enters a state. `to` is `submitter` /// A mail to send when a record enters a state. `to` is `submitter`
/// (the record's `email` field, which the lint then requires the /// (the record's `email` field, which the lint then requires the
/// forms to collect) or one fixed address. Subject and body are /// forms to collect), one fixed address, or a field of the record
/// templates: `{field}` for any field of the record, plus `{site}` /// this one belongs to: `{notice.email}` when the form has a
/// (the instance's public URL), `{chain}` (the record id), `{email}`, /// `type: record` field called `notice`. That is how an answer to a
/// `{bucket}` and `{state}`. The body is markdown; the mailer sends it /// notice reaches whoever posted it. Subject and body are templates:
/// as plain text with an HTML alternative. /// `{field}` for any field of the record, `{notice.field}` for one of
/// the record it belongs to, plus `{site}` (the instance's public
/// URL), `{chain}` (the record id), `{email}`, `{bucket}` and
/// `{state}`. The body is markdown; the mailer sends it as plain text
/// with an HTML alternative.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct MailSpec { pub struct MailSpec {
+19 -3
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User}; use crate::auth::{current_user, User};
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition}; use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, RowLink, SiteConfig, Transition};
use crate::i18n::t; use crate::i18n::t;
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource}; use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
@@ -1301,6 +1301,7 @@ fn AlternativeCard(
alternative=alt_name.clone() alternative=alt_name.clone()
feature_name=feature_name.clone() feature_name=feature_name.clone()
transitions=spec.transitions.clone() transitions=spec.transitions.clone()
link=spec.link.clone()
empty=spec.empty.clone() empty=spec.empty.clone()
pending_transitions=pending_transitions pending_transitions=pending_transitions
transition_batch=transition_batch transition_batch=transition_batch
@@ -1753,6 +1754,7 @@ fn ResourceFeature(
alternative: String, alternative: String,
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
link: Option<RowLink>,
empty: Option<String>, empty: Option<String>,
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>,
@@ -1787,6 +1789,7 @@ fn ResourceFeature(
{move || { {move || {
let feature_name = feature_name.clone(); let feature_name = feature_name.clone();
let transitions = transitions.clone(); let transitions = transitions.clone();
let link = link.clone();
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string()); let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
data.get() data.get()
.map(|res| match res { .map(|res| match res {
@@ -1795,6 +1798,7 @@ fn ResourceFeature(
<ResourceValue <ResourceValue
feature_name=feature_name feature_name=feature_name
transitions=transitions transitions=transitions
link=link
empty=empty empty=empty
value=value value=value
pending_transitions=pending_transitions pending_transitions=pending_transitions
@@ -1822,6 +1826,7 @@ fn ResourceFeature(
fn ResourceValue( fn ResourceValue(
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
link: Option<RowLink>,
empty: String, empty: String,
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>>,
@@ -1853,11 +1858,13 @@ fn ResourceValue(
children={ children={
let feature_name = feature_name.clone(); let feature_name = feature_name.clone();
let transitions = transitions.clone(); let transitions = transitions.clone();
let link = link.clone();
move |answer: Answer| { move |answer: Answer| {
view! { view! {
<AnswerRow <AnswerRow
feature_name=feature_name.clone() feature_name=feature_name.clone()
transitions=transitions.clone() transitions=transitions.clone()
link=link.clone()
answer=answer answer=answer
pending_transitions=pending_transitions pending_transitions=pending_transitions
transition_batch=transition_batch transition_batch=transition_batch
@@ -2116,6 +2123,7 @@ fn SelectField(
fn AnswerRow( fn AnswerRow(
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
link: Option<RowLink>,
answer: Answer, answer: Answer,
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>,
@@ -2148,6 +2156,7 @@ fn AnswerRow(
let state = answer.state.clone(); let state = answer.state.clone();
let item_id = answer.id.clone(); let item_id = answer.id.clone();
let key = (feature_name.clone(), item_id.clone()); let key = (feature_name.clone(), item_id.clone());
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
view! { view! {
<article class="answer-row" class:decided=move || decided> <article class="answer-row" class:decided=move || decided>
@@ -2195,6 +2204,9 @@ fn AnswerRow(
<span class="answer-state">{state}</span> <span class="answer-state">{state}</span>
<span class="answer-time">{format_ms(submitted_ms)}</span> <span class="answer-time">{format_ms(submitted_ms)}</span>
</div> </div>
// The page about this record, when the list offers one:
// where a reader answers a notice.
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
// What the person has written back since, oldest first: the // What the person has written back since, oldest first: the
// conversation beside the case, which a desk could not see // conversation beside the case, which a desk could not see
// before. A note moves nothing, so there is no button here. // before. A note moves nothing, so there is no button here.
@@ -2338,13 +2350,17 @@ pub async fn get_question(
path: String, path: String,
chain: Option<String>, chain: Option<String>,
) -> Result<Option<Page>, ServerFnError> { ) -> Result<Option<Page>, ServerFnError> {
use crate::content::{path_matches, resolve_question}; use crate::auth::{User, SESSION_USER_KEY};
use crate::content::{fill_from_user, path_matches, resolve_question};
use crate::server::AppState; use crate::server::AppState;
let state = expect_context::<AppState>(); let state = expect_context::<AppState>();
let questions = state.questions.load(); let questions = state.questions.load();
let Some(question) = resolve_question(&questions, &path) else { let Some(mut question) = resolve_question(&questions, &path) else {
return Ok(None); return Ok(None);
}; };
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
fill_from_user(&mut question, user.as_ref());
let mut chain_gate = None; let mut chain_gate = None;
if let Some(target) = &question.requires_chain { if let Some(target) = &question.requires_chain {
// The claimed lineage must verifiably end at the required // The claimed lineage must verifiably end at the required
+85 -1
View File
@@ -207,6 +207,35 @@ impl Question {
} }
} }
/// The signed-in person's own details into the fields that ask for
/// them: `value: "{user.email}"` becomes their address and the field
/// hidden, since asking someone who is known to say who they are is
/// the form doubting them. Without a session the placeholder is
/// emptied, and the field asks. Only the whole of a value is filled
/// this way; a value that mixes a user placeholder with other text is
/// left alone.
pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>) {
for alt in &mut question.alternatives {
for feature in &mut alt.features {
for req in &mut feature.requirements {
let Some(value) = req.value.as_deref() else { continue };
let filled = match value {
"{user.email}" => user.and_then(|u| u.email.clone()),
"{user.name}" => user.map(|u| u.display_name.clone()),
_ => continue,
};
match filled.filter(|v| !v.trim().is_empty()) {
Some(v) => {
req.value = Some(v);
req.kind = "hidden".to_string();
}
None => req.value = None,
}
}
}
}
}
/// An alternative's `action` may carry `{field}` / `{field.sub}` /// An alternative's `action` may carry `{field}` / `{field.sub}`
/// placeholders filled from the submitted responses - `/shape/{curve.key}` /// placeholders filled from the submitted responses - `/shape/{curve.key}`
/// lands on the dynamic page `/shape/[key]` for the key the visitor's /// lands on the dynamic page `/shape/[key]` for the key the visitor's
@@ -439,6 +468,14 @@ pub struct ResourceSpec {
/// didn't declare. `Kv` sources only. /// didn't declare. `Kv` sources only.
#[serde(default)] #[serde(default)]
pub transitions: Vec<Transition>, pub transitions: Vec<Transition>,
/// A way from each listed record to a page about it: `to` is the
/// page, with `{id}` for the record's id and `{field}` for any of
/// its fields, and `label` the words on the link. The other half
/// of `type: record`, seen from the list: a notice on a board links
/// to the page where a neighbour answers it, and that page's form
/// carries the notice's id back in.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<RowLink>,
/// A jq filter (evaluated via `jaq`, no shell-out) reshaping the /// A jq filter (evaluated via `jaq`, no shell-out) reshaping the
/// fetched value before it reaches the frontend - e.g. /// fetched value before it reaches the frontend - e.g.
/// `.[] | {name, url: .html_url}`. `None` returns it as-is. /// `.[] | {name, url: .html_url}`. `None` returns it as-is.
@@ -542,7 +579,10 @@ pub struct Requirement {
/// A preset value the field starts with. On a dynamic page the /// A preset value the field starts with. On a dynamic page the
/// URL segment substitutes into `{name}` placeholders here, like /// URL segment substitutes into `{name}` placeholders here, like
/// resource keys - `value: "{key}"` on a hidden field carries the /// resource keys - `value: "{key}"` on a hidden field carries the
/// page's key into the answer. /// page's key into the answer. `{user.email}` and `{user.name}`
/// are the signed-in person's, and a field they fill is carried
/// rather than asked (see `fill_from_user`); for a visitor they
/// are empty, and the field asks as usual.
#[serde(default)] #[serde(default)]
pub value: Option<String>, pub value: Option<String>,
/// `type: record` only - the bucket the referenced record lives in. /// `type: record` only - the bucket the referenced record lives in.
@@ -574,6 +614,30 @@ pub struct Requirement {
/// listing may ask about a record's own contents: one field, one /// listing may ask about a record's own contents: one field, one
/// value, compared as strings. Anything more is a query language, and /// value, compared as strings. Anything more is a query language, and
/// a jq filter is already there for shaping what comes back. /// a jq filter is already there for shaping what comes back.
/// A link from every listed record to a page about it (`ResourceSpec.link`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RowLink {
pub to: String,
pub label: String,
}
impl RowLink {
/// The link for one record: `{id}` is the record's id, `{name}`
/// any string field of its responses. A placeholder nothing fills
/// stays as it is, which is visible, rather than a wrong page.
pub fn href(&self, id: &str, responses: &serde_json::Value) -> String {
let mut out = self.to.replace("{id}", id);
if let Some(fields) = responses.as_object() {
for (k, v) in fields {
if let Some(s) = v.as_str() {
out = out.replace(&format!("{{{k}}}"), s);
}
}
}
out
}
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FieldIs { pub struct FieldIs {
pub field: String, pub field: String,
@@ -1790,6 +1854,26 @@ alternatives:
assert_eq!(render_inline_markdown("a\nb"), "a b"); assert_eq!(render_inline_markdown("a\nb"), "a b");
} }
#[test]
fn a_signed_in_person_is_not_asked_who_they_are() {
let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n";
let q: Question = serde_yaml::from_str(yaml).unwrap();
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], email: Some("kari@vel.no".into()) };
let mut signed_in = q.clone();
fill_from_user(&mut signed_in, Some(&user));
let reqs = &signed_in.alternatives[0].features[0].requirements;
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no")));
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N")));
// Mixed text is not a user field, and is left as written.
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
// A visitor is asked: the placeholder goes, the field stays.
let mut visitor = q.clone();
fill_from_user(&mut visitor, None);
let reqs = &visitor.alternatives[0].features[0].requirements;
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None));
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
}
#[test] #[test]
fn markdown_drops_html_and_unsafe_links() { fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z"); assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
+21
View File
@@ -135,6 +135,27 @@ pub fn validate_questions(
} }
} }
// A mail to the record this one belongs to goes through a field of
// the form: `{notice.email}` needs a `type: record` field called
// `notice` on every form that records into the bucket, or the mail
// has nowhere to go.
for (bucket, schema) in aggregates {
for (state, spec) in &schema.mail_for_state {
let Some(parent) = crate::mail::parent_of(&spec.to) else { continue };
for question in questions.values() {
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
if !has {
anyhow::bail!(
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
question.id, alternative.name, spec.to
);
}
}
}
}
}
// A state that grants a group makes an account for the person the // A state that grants a group makes an account for the person the
// record is about, so the record has to know who they are. Same // record is about, so the record has to know who they are. Same
// shape as an invite's own rule, one step further back: the form // shape as an invite's own rule, one step further back: the form
+63 -2
View File
@@ -251,6 +251,49 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
vars vars
} }
/// The record field a `to` reaches through, when it is neither
/// `submitter` nor an address: `{notice.email}` gives `notice`.
pub fn parent_of(to: &str) -> Option<&str> {
let inner = to.trim().strip_prefix('{')?.strip_suffix('}')?;
let (field, _) = inner.split_once('.')?;
(!field.is_empty() && !inner.contains(char::is_whitespace)).then_some(field)
}
/// The fields of every record this one belongs to, as `{field.name}`:
/// for each `type: record` field of the forms that record into
/// `bucket`, the record it names, read from its own bucket. A
/// reference that does not resolve adds nothing, and a template that
/// needed it renders empty there.
async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map<String, serde_json::Value>) {
let questions = state.questions.load();
let refs: Vec<(String, String)> = questions
.values()
.flat_map(|q| q.alternatives.iter())
.filter(|a| a.record_as.as_deref() == Some(bucket))
.flat_map(|a| a.features.iter().flat_map(|f| f.requirements.iter()))
.filter(|r| r.kind == "record")
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
.collect();
for (field, of) in refs {
let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue };
let Ok(store) = state.jetstream.get_key_value(&of).await else { continue };
let Ok(Some(bytes)) = store.get(id).await else { continue };
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
if let Some(fields) = parent.responses.as_object() {
for (k, v) in fields {
let text = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
};
vars.insert(format!("{field}.{k}"), text.into());
}
}
vars.insert(format!("{field}.id"), parent.id.into());
vars.insert(format!("{field}.state"), parent.state.into());
}
}
async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> { async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> {
let mut headers = async_nats::HeaderMap::new(); let mut headers = async_nats::HeaderMap::new();
headers.insert("Nats-Msg-Id", mail.id.as_str()); headers.insert("Nats-Msg-Id", mail.id.as_str());
@@ -277,7 +320,8 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent"); tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
return; return;
}; };
let 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;
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(),
@@ -287,7 +331,13 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
} }
} }
} else { } else {
spec.to.clone() // A field of the record this one belongs to, or an address.
let to = render(&spec.to, &vars);
if !to.contains('@') {
tracing::warn!(bucket, id, to_state, to = %spec.to, "the mail's address did not resolve to one - not sent");
return;
}
to
}; };
let text = render(&spec.body, &vars); let text = render(&spec.body, &vars);
let mail = Outgoing { let mail = Outgoing {
@@ -588,6 +638,17 @@ mod tests {
} }
} }
#[test]
fn a_mail_reaches_through_a_record_field_and_nothing_else() {
assert_eq!(parent_of("{notice.email}"), Some("notice"));
assert_eq!(parent_of(" {case.contact} "), Some("case"));
assert_eq!(parent_of("submitter"), None);
assert_eq!(parent_of("post@vel.no"), None);
assert_eq!(parent_of("{email}"), None);
assert_eq!(parent_of("{.email}"), None);
assert_eq!(parent_of("{not a.field}"), None);
}
#[test] #[test]
fn templates_take_fields_and_leave_stray_braces_alone() { fn templates_take_fields_and_leave_stray_braces_alone() {
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null }); let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
+16
View File
@@ -1094,6 +1094,22 @@ textarea:focus {
color: var(--accent); color: var(--accent);
} }
/* The page about a listed record, when the list offers one. */
.answer-link {
display: inline-block;
margin-top: 0.4rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--accent);
text-decoration: none;
border-bottom: 0.1rem solid var(--accent);
}
.answer-link:hover {
color: var(--ink);
border-bottom-color: var(--ink);
}
.answer-actions { .answer-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;