A stored file on a record shows as its name and opens from the row
Test / test (pull_request) Successful in 1m16s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 17:38:32 +02:00
co-authored by Claude Opus 5.5
parent a822f2175f
commit c766dedc0e
3 changed files with 46 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@ content repos pin the tag they run (see the end of this file).
- The header knows who is here: on a site with sign-in, a link top right to sign in, or the person's name and the way out. A page with a `slideshow` gets the wordmark as a bar of its own, the strip full width under it, and the question after.
- "My cases": `where: { field: email, equals: "{user.email}" }` lists the records that carry the signed-in person's own address, filled from the session and never from the caller; nobody signed in matches nothing. An attachment is fetched only through a list that shows its record, so a list of my cases opens my files and nobody else's.
- A stored file on a listed record shows as the name it was sent with and opens from the row, for whoever the list shows the record to. It used to show as its storage key, a string nobody could open.
- Form controls scale with the text around them (em, not rem): fields, pills, buttons and labels follow a site's body size. The date picker's calendar icon is the size of the text beside it, and the file picker's button takes the field's colours instead of the browser's.
- `KANIDM_GROUP_PREFIX`: a site on a Kanidm it shares with others keeps plain group names in its content; the prefix is put on when portal adds or removes a member, and the client's claim map takes it off.
- Fixed: `maps` never reached the browser (`serde(skip)`), so a page with a map drew it on the server and dropped it on hydration.
+38
View File
@@ -1400,6 +1400,7 @@ fn AlternativeCard(
feature_name=feature_name.clone()
transitions=spec.transitions.clone()
link=spec.link.clone()
bucket=spec.bucket().map(str::to_string)
empty=spec.empty.clone()
pending_transitions=pending_transitions
transition_batch=transition_batch
@@ -1853,6 +1854,7 @@ fn ResourceFeature(
feature_name: String,
transitions: Vec<Transition>,
link: Option<RowLink>,
bucket: Option<String>,
empty: Option<String>,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
@@ -1888,6 +1890,7 @@ fn ResourceFeature(
let feature_name = feature_name.clone();
let transitions = transitions.clone();
let link = link.clone();
let bucket = bucket.clone();
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
data.get()
.map(|res| match res {
@@ -1897,6 +1900,7 @@ fn ResourceFeature(
feature_name=feature_name
transitions=transitions
link=link
bucket=bucket
empty=empty
value=value
pending_transitions=pending_transitions
@@ -1925,6 +1929,7 @@ fn ResourceValue(
feature_name: String,
transitions: Vec<Transition>,
link: Option<RowLink>,
bucket: Option<String>,
empty: String,
value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
@@ -1957,12 +1962,14 @@ fn ResourceValue(
let feature_name = feature_name.clone();
let transitions = transitions.clone();
let link = link.clone();
let bucket = bucket.clone();
move |answer: Answer| {
view! {
<AnswerRow
feature_name=feature_name.clone()
transitions=transitions.clone()
link=link.clone()
bucket=bucket.clone()
answer=answer
pending_transitions=pending_transitions
transition_batch=transition_batch
@@ -2217,11 +2224,20 @@ fn SelectField(
}
}
/// The file's own name, if a field's value is an upload's key: a
/// uuid, a dash, and the name it was sent with (`upload.rs`).
pub fn uploaded_filename(value: &str) -> Option<&str> {
let (id, rest) = value.split_at_checked(36)?;
let shaped = id.char_indices().all(|(i, c)| if matches!(i, 8 | 13 | 18 | 23) { c == '-' } else { c.is_ascii_hexdigit() });
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
}
#[component]
fn AnswerRow(
feature_name: String,
transitions: Vec<Transition>,
link: Option<RowLink>,
bucket: Option<String>,
answer: Answer,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
@@ -2255,6 +2271,9 @@ fn AnswerRow(
let item_id = answer.id.clone();
let key = (feature_name.clone(), item_id.clone());
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
// Where a stored file on this record is fetched from: the record
// names it, never the key (see `upload::attachment`).
let attachment_base = bucket.map(|b| format!("/attachment/{b}/{}", answer.id));
view! {
<article class="answer-row" class:decided=move || decided>
@@ -2288,6 +2307,17 @@ fn AnswerRow(
.into_any();
}
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
// A stored file shows as its name, and opens.
if let (Some(base), Some(filename)) = (attachment_base.clone(), uploaded_filename(&text)) {
let href = format!("{base}/{key}");
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<a class="answer-field-value answer-file" href=href rel="external">{filename.to_string()}</a>
</div>
}
.into_any();
}
view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
@@ -3028,6 +3058,14 @@ mod responsible_tests {
Responsible { name: "R".into(), contact: contact.into(), group: group.map(String::from) }
}
#[test]
fn an_uploads_key_shows_as_the_name_it_was_sent_with() {
assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-rapport 2026.pdf"), Some("rapport 2026.pdf"));
assert_eq!(super::uploaded_filename("rapport.pdf"), None);
assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-"), None);
assert_eq!(super::uploaded_filename("Storgata 1, 1850 Mysen - se vedlegg i e-post fra oss"), None);
}
#[test]
fn only_the_person_a_page_names_may_suggest_a_change_to_it() {
assert!(is_responsible(Some(&user(Some("kari@vel.no"), &[])), &named("mailto:Kari@Vel.no", None)));
+7
View File
@@ -1269,6 +1269,13 @@ textarea:focus {
color: var(--accent);
}
/* A stored file on a record: its name, and it opens. */
.answer-file {
color: var(--accent);
text-decoration: none;
border-bottom: 0.06rem solid var(--accent);
}
/* The page about a listed record, when the list offers one. */
.answer-link {
display: inline-block;