diff --git a/CHANGELOG.md b/CHANGELOG.md index 38da7d1..8771de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/app.rs b/src/app.rs index 41496ae..daad244 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, link: Option, + bucket: Option, empty: Option, pending_transitions: RwSignal>, transition_batch: ServerAction, @@ -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, link: Option, + bucket: Option, empty: String, value: serde_json::Value, pending_transitions: RwSignal>, @@ -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! { 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, link: Option, + bucket: Option, answer: Answer, pending_transitions: RwSignal>, transition_batch: ServerAction, @@ -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! {
@@ -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! { + + } + .into_any(); + } view! {
{key} @@ -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))); diff --git a/style/main.css b/style/main.css index 7ec055e..852fa84 100644 --- a/style/main.css +++ b/style/main.css @@ -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;