git-ents.gitmain
⌘K
foforge
render.rs487 lines · 18.6 KB · rusthistorycomment on this file
1//! The generic, schema-driven list/view rendering mechanism -- the UI
2//! analog of the gate executor named in this crate's development-plan
3//! row: one reflection walk over any `#[derive(Facet)]` entity's
4//! [`facet::Shape`], reused for every kernel entity this crate lists or
5//! shows, rather than one hand-written renderer per entity type. The walk
6//! itself is [`ents_forge::present`]'s: presentation policy (the `ents`
7//! attributes -- skip, `skip_empty`, id-abbreviation, head/col column
8//! selection) is declared once on the entity's own fields and obeyed here
9//! exactly as the CLI obeys it; this module keeps only what is genuinely
10//! web-shaped -- Markup wrapping, href columns, long-token cell breaking,
11//! and the unreadable-entity degradation cards.
12//!
13//! The binding rule this module exists to uphold: nothing here ever
14//! matches on *which* concrete type it was handed. [`fields`] walks
15//! whatever [`facet::Shape`] the type reflects, by field name and
16//! position, exactly the same way for [`ents_model::Member`],
17//! [`ents_model::Effect`], [`ents_model::Redaction`], or
18//! [`ents_model::Account`]. A page that genuinely needs to know it is
19//! showing a comment (to render an anchor's projected diff) or a
20//! toolchain (to render a recipe's provenance) is not a gap in this
21//! module -- it is [`crate::pages::comments`] or [`crate::pages::toolchains`]
22//! choosing a legitimate custom view instead of this generic one, exactly
23//! as this crate's development-plan row anticipates.
24
25use facet::Facet;
26use maud::{Markup, html};
27
28/// One field's name and rendered value, in declaration order.
29pub type FieldRow = (&'static str, String);
30
31/// Reflect over `value`'s [`facet::Shape`] and return one `(name, value)`
32/// pair per field the entity's own `ents` attributes present in a view:
33/// `ents::skip` fields are omitted, `ents::skip_empty` fields omitted when
34/// empty, id-valued fields abbreviated, and the `ents::body` field ordered
35/// last -- [`ents_forge::present::view`]'s policy, one walk shared with
36/// the CLI's `show` ([`ents_forge::present::fields`]).
37///
38/// A field's value renders via its own `Display` impl when it has one
39/// (plain text, no `Type::Foo(...)` wrapper), falling back to `Debug` so
40/// an enum without `Display` still shows its variant name rather than an
41/// opaque placeholder. A non-struct `T` renders as an empty list, not a
42/// panic -- reflection is a UI convenience, never a correctness path.
43///
44/// # Examples
45///
46/// ```
47/// use ents_model::{Member, Provenance};
48///
49/// let member = Member::new("jdc", "ssh-ed25519 AAAA... jdc", Provenance::AdminRegistered);
50/// let rows = ents_web::render::fields(&member);
51/// assert_eq!(rows[0].0, "id");
52/// assert_eq!(rows[1].0, "key");
53/// assert!(rows[1].1.contains("ssh-ed25519"));
54/// assert_eq!(rows[2].0, "state");
55/// assert_eq!(rows[2].1, "active");
56/// ```
57// @relation(model.presentation, scope=function)
58#[must_use]
59pub fn fields<T: Facet<'static>>(value: &T) -> Vec<FieldRow> {
60 let walked = ents_forge::present::fields(value);
61 let (body, lines): (Vec<_>, Vec<_>) = walked
62 .into_iter()
63 .filter(|field| !(field.skip_empty && field.empty))
64 .partition(|field| field.body);
65 lines
66 .into_iter()
67 .chain(body)
68 .map(|field| (field.name, field.value))
69 .collect()
70}
71
72/// The `(name, value)` list columns `value`'s own `ents` attributes
73/// select: `ents::head` fields first, then `ents::col` fields, matching
74/// [`ents_forge::present::columns`]'s order -- or, for an entity declaring
75/// no column at all, every walked field, so an unannotated entity still
76/// lists in full rather than as a bare id column.
77fn list_columns<T: Facet<'static>>(value: &T) -> Vec<FieldRow> {
78 let walked = ents_forge::present::fields(value);
79 if walked.iter().any(|field| field.head || field.col) {
80 let heads = walked.iter().filter(|field| field.head);
81 let cols = walked.iter().filter(|field| field.col && !field.head);
82 heads
83 .chain(cols)
84 .map(|field| (field.name, field.value.clone()))
85 .collect()
86 } else {
87 walked
88 .into_iter()
89 .map(|field| (field.name, field.value))
90 .collect()
91 }
92}
93
94/// A definition-list view of one entity's fields -- the generic "show"
95/// page every kernel entity this crate exposes uses.
96///
97/// # Examples
98///
99/// ```
100/// use ents_model::{Account, MemberId};
101///
102/// let account = Account { member: MemberId::new("jdc"), login: "jdc@ents.test".to_owned() };
103/// let markup = ents_web::render::view(&account);
104/// assert!(markup.into_string().contains("login"));
105/// ```
106#[must_use]
107pub fn view<T: Facet<'static>>(value: &T) -> Markup {
108 let rows = fields(value);
109 html! {
110 div.card {
111 dl.entity-view {
112 @for (name, rendered) in &rows {
113 dt { (name) }
114 dd { (rendered) }
115 }
116 }
117 }
118 }
119}
120
121/// A table listing `rows`, one row per `(id, entity)` pair, columns taken
122/// from the first entity's own reflected field names -- the generic
123/// "list" page every kernel entity this crate exposes uses. Which columns
124/// render is the entity's own `ents` declaration ([`list_columns`]): its
125/// `ents::head` fields lead, its `ents::col` fields follow -- the same
126/// selection and order the CLI's `list` derives -- with the full field
127/// walk as the fallback for an entity declaring no column.
128///
129/// `id_header` names the leading column holding each entry's key (a
130/// username, an effect name, a redaction id -- whatever names the ref this
131/// listing was read from, which is never itself a field on the entity).
132///
133/// Rows are the readable entities only: a ref whose stored tree this
134/// build's `#[derive(Facet)]` shape could not read back is not this
135/// table's row to render -- the page surfaces it through
136/// [`unreadable_disclosure`] beside this table instead (the one place
137/// unreadable entities render, for every family alike), and its own show
138/// page still renders [`unreadable`]'s marker card.
139///
140/// # Examples
141///
142/// ```
143/// use ents_model::{Member, Provenance};
144///
145/// let rows = vec![
146/// ("jdc".to_owned(), Member::new("jdc", "key-a", Provenance::AdminRegistered)),
147/// ];
148/// let rendered = ents_web::render::list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
149/// assert!(rendered.contains("jdc"));
150/// assert!(rendered.contains("key-a"));
151/// ```
152#[must_use]
153pub fn list_table<T: Facet<'static>>(
154 rows: &[(String, T)],
155 id_header: &str,
156 href_for: impl Fn(&str) -> String,
157) -> Markup {
158 let field_names: Vec<&'static str> = rows
159 .first()
160 .map(|(_, entity)| {
161 list_columns(entity)
162 .into_iter()
163 .map(|(name, _)| name)
164 .collect()
165 })
166 .unwrap_or_default();
167 html! {
168 div.card {
169 table.entity-list {
170 thead {
171 tr {
172 th { (id_header) }
173 @for name in &field_names {
174 th { (name) }
175 }
176 }
177 }
178 tbody {
179 @for (id, entity) in rows {
180 tr {
181 td { a href=(href_for(id)) { (id) } }
182 @for (_, rendered) in list_columns(entity) {
183 td.long-token[has_long_token(&rendered)] { (rendered) }
184 }
185 }
186 }
187 }
188 }
189 }
190 }
191}
192
193/// Whether `value` holds a token no wrap opportunity ever splits -- an ssh
194/// key's base64 body, an unbroken hash -- long enough (over 40 characters)
195/// that its cell must be allowed to break mid-token (`.long-token`'s
196/// `break-all`) or it starves every other column of the table's width.
197/// Ordinary short values keep word-boundary wrapping so a variant name
198/// like `AdminRegistered` never shreds.
199fn has_long_token(value: &str) -> bool {
200 value.split_whitespace().any(|token| token.len() > 40)
201}
202
203/// A muted marker card for one entity this crate could not reflect -- the
204/// `GET /{family}/{id}` show-page counterpart to [`list_table`]'s per-row
205/// marker: the same "unreadable" note, plus `detail` (the underlying
206/// deserialization error) rendered verbatim in muted monospace, so an
207/// operator can diagnose the schema mismatch without leaving the browser.
208/// Never a 500 -- reading an older or unrelated schema's tree degrades to
209/// this card, exactly as [`list_table`] degrades one row of a listing.
210///
211/// # Examples
212///
213/// ```
214/// let rendered = ents_web::render::unreadable("object ... is not a blob").into_string();
215/// assert!(rendered.contains("unreadable"));
216/// assert!(rendered.contains("is not a blob"));
217/// ```
218#[must_use]
219pub fn unreadable(detail: &str) -> Markup {
220 html! {
221 div.card {
222 div.card-row.unreadable {
223 span { "unreadable \u{2014} written by an older schema" }
224 }
225 div.card-row {
226 code.unreadable-detail { (detail) }
227 }
228 }
229 }
230}
231
232/// The subtle "this page has unreadable entities" disclosure a list page
233/// renders when one or more refs under its prefix failed to read back as
234/// this build's entity shape: a muted `<details>` badge ("N unreadable",
235/// warning glyph) that expands -- no JS, just the element's own toggle --
236/// to a small card listing each failed refname and its error text. One
237/// component for every entity family (members, effects, redactions,
238/// toolchains, comments, issues), so unreadable entities are surfaced the
239/// same way everywhere instead of a per-page mix of inline rows and
240/// silent gaps. Renders nothing at all when `items` is empty, so a
241/// healthy page carries no extra markup.
242///
243/// # Examples
244///
245/// ```
246/// let items = vec![(
247/// "refs/meta/comments/legacy".to_owned(),
248/// "object ... is not a blob".to_owned(),
249/// )];
250/// let rendered = ents_web::render::unreadable_disclosure(&items).into_string();
251/// assert!(rendered.contains("<details"));
252/// assert!(rendered.contains("1 unreadable"));
253/// assert!(rendered.contains("refs/meta/comments/legacy"));
254/// assert!(ents_web::render::unreadable_disclosure(&[]).into_string().is_empty());
255/// ```
256#[must_use]
257pub fn unreadable_disclosure(items: &[(String, String)]) -> Markup {
258 if items.is_empty() {
259 return html! {};
260 }
261 html! {
262 details.unreadable-note {
263 summary {
264 "\u{26a0} " (items.len()) " unreadable"
265 }
266 div.card {
267 dl.entity-view {
268 @for (refname, error) in items {
269 dt { (refname) }
270 dd { (error) }
271 }
272 }
273 }
274 }
275 }
276}
277
278/// A key-value properties table for a rendered document's own metadata --
279/// Markdown frontmatter ([`crate::markdown`]) and an AsciiDoc header's
280/// attribute entries ([`crate::asciidoc`]) both render through this one
281/// component, above the document body, styled by `ents.css`'s
282/// `.doc-props` rules on top of the same `.entity-view` definition-list
283/// look every generic entity view already has. Values are plain text
284/// (maud-escaped as any interpolation is); a nested structure the caller
285/// chose not to parse arrives here as its raw text and renders verbatim
286/// (`.doc-props dd` preserves its line breaks). Renders nothing at all
287/// when `entries` is empty, so a document with no metadata carries no
288/// empty table.
289///
290/// # Examples
291///
292/// ```
293/// let entries = vec![("title".to_owned(), "Design Notes".to_owned())];
294/// let rendered = ents_web::render::properties_table(&entries).into_string();
295/// assert!(rendered.contains("doc-props"));
296/// assert!(rendered.contains("Design Notes"));
297/// assert!(ents_web::render::properties_table(&[]).into_string().is_empty());
298/// ```
299#[must_use]
300pub fn properties_table(entries: &[(String, String)]) -> Markup {
301 if entries.is_empty() {
302 return html! {};
303 }
304 html! {
305 dl.entity-view.doc-props {
306 @for (key, value) in entries {
307 dt { (key) }
308 dd { (value) }
309 }
310 }
311 }
312}
313
314/// A list of plain strings with no reflected entity behind them (inbox
315/// entries, toolchain names) -- deliberately not the [`fields`] mechanism,
316/// since there is no struct to reflect over, only a bare list of ids.
317#[must_use]
318pub fn string_list(rows: &[String], href_for: impl Fn(&str) -> String) -> Markup {
319 html! {
320 div.card {
321 ul.string-list {
322 @for row in rows {
323 li { a href=(href_for(row)) { (row) } }
324 }
325 }
326 }
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 #![allow(clippy::expect_used, reason = "unit test")]
333
334 use ents_model::{Account, Effect, Member, MemberId, MemberState, Provenance, Redaction};
335 use rstest::rstest;
336
337 use super::*;
338
339 #[rstest]
340 // @relation(roots.web-agnostic, scope=function, role=Verifies)
341 fn fields_walks_every_declared_field_in_order_for_any_kernel_entity() {
342 let member = Member::new(
343 "jdc",
344 "ssh-ed25519 AAAA... jdc",
345 Provenance::AdminRegistered,
346 );
347 let rows = fields(&member);
348 assert_eq!(
349 rows.iter().map(|(name, _)| *name).collect::<Vec<_>>(),
350 vec!["id", "key", "state", "provenance"]
351 );
352 }
353
354 #[rstest]
355 // @relation(roots.web-agnostic, scope=function, role=Verifies)
356 fn an_enum_field_renders_its_variant_name_not_a_placeholder() {
357 let member = Member::new("jdc", "key", Provenance::AdminRegistered);
358 let rows = fields(&member);
359 let (_, state) = rows
360 .iter()
361 .find(|(name, _)| *name == "state")
362 .expect("state field");
363 assert_eq!(state, "active");
364 assert_eq!(member.state, MemberState::Active);
365 }
366
367 #[rstest]
368 #[case::member(Member::new("jdc", "k", Provenance::AdminRegistered))]
369 // @relation(roots.web-agnostic, scope=function, role=Verifies)
370 fn the_same_generic_view_renders_every_entity_type(#[case] member: Member) {
371 // Same call, no type-specific branch -- this is the whole point of
372 // the generic mechanism this module exists to prove. Each call's
373 // markup is asserted non-empty and containing a field name real to
374 // that entity, so this is a render check, not a discarded call.
375 assert!(view(&member).into_string().contains("provenance"));
376 assert!(
377 view(&Effect {
378 name: "unit".to_owned(),
379 trigger: "rev(refs/heads/main)".to_owned(),
380 toolchains: vec![],
381 run: "true".to_owned(),
382 })
383 .into_string()
384 .contains("trigger")
385 );
386 assert!(
387 view(&Redaction::new(
388 gix_hash::ObjectId::null(gix_hash::Kind::Sha1),
389 "why"
390 ))
391 .into_string()
392 .contains("reason")
393 );
394 assert!(
395 view(&Account {
396 member: MemberId::new("jdc"),
397 login: "jdc@ents.test".to_owned(),
398 })
399 .into_string()
400 .contains("login")
401 );
402 }
403
404 /// The `ents` attributes declared on the entity's own fields govern
405 /// the web exactly as they govern the CLI: `ents::skip` hides the
406 /// refname-bound name, `ents::skip_empty` hides an empty set, and the
407 /// list narrows to the declared `ents::col` columns.
408 #[rstest]
409 // @relation(model.presentation, roots.web-agnostic, scope=function, role=Verifies)
410 fn ents_attributes_govern_the_view_and_the_list_columns() {
411 let effect = Effect {
412 name: "unit".to_owned(),
413 trigger: "rev(refs/heads/main)".to_owned(),
414 toolchains: vec![],
415 run: "true".to_owned(),
416 };
417 let names: Vec<_> = fields(&effect).into_iter().map(|(name, _)| name).collect();
418 assert_eq!(names, vec!["trigger", "run"]);
419
420 let rows = vec![("unit".to_owned(), effect)];
421 let markup = list_table(&rows, "name", |id| format!("/effects/{id}")).into_string();
422 assert!(markup.contains("trigger") && markup.contains("rev(refs/heads/main)"));
423 assert!(
424 !markup.contains("<th>run</th>"),
425 "only the declared columns render: {markup}"
426 );
427 }
428
429 #[rstest]
430 // @relation(roots.web-agnostic, scope=function, role=Verifies)
431 fn list_table_derives_its_columns_from_the_first_rows_own_shape() {
432 let rows = vec![(
433 "jdc".to_owned(),
434 Member::new("jdc", "key", Provenance::AdminRegistered),
435 )];
436 let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
437 assert!(markup.contains("username"));
438 assert!(markup.contains("key"));
439 assert!(markup.contains("jdc"));
440 }
441
442 #[rstest]
443 // @relation(roots.web-agnostic, scope=function, role=Verifies)
444 fn list_table_breaks_only_the_cell_holding_a_long_unbroken_token() {
445 let key = format!("ssh-ed25519 {} jdc@host", "A".repeat(68));
446 let rows = vec![(
447 "jdc".to_owned(),
448 Member::new("jdc", key, Provenance::AdminRegistered),
449 )];
450 let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
451 // Exactly one cell carries the class: the key's; `AdminRegistered`
452 // and the short id stay word-boundary-wrapped.
453 assert_eq!(markup.matches("class=\"long-token\"").count(), 1);
454 }
455
456 #[rstest]
457 // @relation(roots.web-agnostic, scope=function, role=Verifies)
458 fn unreadable_disclosure_lists_each_failed_ref_behind_a_details_toggle() {
459 let items = vec![
460 (
461 "refs/meta/member/legacy".to_owned(),
462 "object ... is not a blob".to_owned(),
463 ),
464 (
465 "refs/meta/member/older".to_owned(),
466 "missing field".to_owned(),
467 ),
468 ];
469 let markup = unreadable_disclosure(&items).into_string();
470 assert!(markup.contains("<details"));
471 assert!(markup.contains("2 unreadable"));
472 assert!(markup.contains("refs/meta/member/legacy"));
473 assert!(markup.contains("missing field"));
474 assert!(
475 unreadable_disclosure(&[]).into_string().is_empty(),
476 "a healthy page carries no disclosure at all"
477 );
478 }
479
480 #[rstest]
481 // @relation(roots.web-agnostic, scope=function, role=Verifies)
482 fn unreadable_card_shows_the_underlying_error() {
483 let markup = unreadable("object deadbeef is not a blob").into_string();
484 assert!(markup.contains("unreadable"));
485 assert!(markup.contains("object deadbeef is not a blob"));
486 }
487}