max / quasi
4 files changed,
+117 insertions,
-9 deletions
| @@ -4888,14 +4888,6 @@ | |||
| 4888 | 4888 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4889 | 4889 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 4890 | 4890 | ||
| 4891 | - | [[patch.unused]] | |
| 4892 | - | name = "synckit-client" | |
| 4893 | - | version = "0.8.0" | |
| 4894 | - | ||
| 4895 | - | [[patch.unused]] | |
| 4896 | - | name = "synckit-config" | |
| 4897 | - | version = "0.2.0" | |
| 4898 | - | ||
| 4899 | 4891 | [[patch.unused]] | |
| 4900 | 4892 | name = "kberg" | |
| 4901 | 4893 | version = "0.1.0" | |
| @@ -4907,3 +4899,11 @@ | |||
| 4907 | 4899 | [[patch.unused]] | |
| 4908 | 4900 | name = "tagtree" | |
| 4909 | 4901 | version = "0.4.0" | |
| 4902 | + | ||
| 4903 | + | [[patch.unused]] | |
| 4904 | + | name = "synckit-client" | |
| 4905 | + | version = "0.8.0" | |
| 4906 | + | ||
| 4907 | + | [[patch.unused]] | |
| 4908 | + | name = "synckit-config" | |
| 4909 | + | version = "0.2.0" |
| @@ -1358,6 +1358,25 @@ | |||
| 1358 | 1358 | pub tokens: Vec<Tag>, | |
| 1359 | 1359 | /// The controls in this cell, in order. | |
| 1360 | 1360 | pub actions: Vec<Act>, | |
| 1361 | + | /// Where this cell's value goes, when the value is itself a link. | |
| 1362 | + | /// | |
| 1363 | + | /// Counted before adding it, the same rule the other two members were held | |
| 1364 | + | /// to: 35 cells across 18 of the MNW server's templates are a value that is | |
| 1365 | + | /// a link, counting only anchors that are not button-styled, so the | |
| 1366 | + | /// `View`/`Edit` anchors [`actions`](Self::actions) already covers are | |
| 1367 | + | /// excluded. A title column is the common one. | |
| 1368 | + | /// | |
| 1369 | + | /// Distinct from [`Cells::activate`], which opens the whole row and can | |
| 1370 | + | /// therefore say only one destination. That is not enough: MNW's | |
| 1371 | + | /// `library_purchases` row carries four, to the item, the creator, the | |
| 1372 | + | /// project and the invoice, and a row-level link would have to drop three | |
| 1373 | + | /// of them. | |
| 1374 | + | /// | |
| 1375 | + | /// Distinct from an [`Act`] too, and the difference is what the reader | |
| 1376 | + | /// sees. An act is a control drawn as one, which is right for `Edit` and | |
| 1377 | + | /// wrong for a title: making every linked value a button would put a row of | |
| 1378 | + | /// bevels down the first column of half the dashboard. | |
| 1379 | + | pub activate: Option<Action>, | |
| 1361 | 1380 | } | |
| 1362 | 1381 | ||
| 1363 | 1382 | impl Cell { | |
| @@ -1367,6 +1386,7 @@ | |||
| 1367 | 1386 | value: value.into(), | |
| 1368 | 1387 | tokens: Vec::new(), | |
| 1369 | 1388 | actions: Vec::new(), | |
| 1389 | + | activate: None, | |
| 1370 | 1390 | } | |
| 1371 | 1391 | } | |
| 1372 | 1392 | ||
| @@ -1379,6 +1399,7 @@ | |||
| 1379 | 1399 | value: String::new(), | |
| 1380 | 1400 | tokens: vec![tag], | |
| 1381 | 1401 | actions: Vec::new(), | |
| 1402 | + | activate: None, | |
| 1382 | 1403 | } | |
| 1383 | 1404 | } | |
| 1384 | 1405 | ||
| @@ -1395,6 +1416,7 @@ | |||
| 1395 | 1416 | value: String::new(), | |
| 1396 | 1417 | tokens: Vec::new(), | |
| 1397 | 1418 | actions: actions.into_iter().collect(), | |
| 1419 | + | activate: None, | |
| 1398 | 1420 | } | |
| 1399 | 1421 | } | |
| 1400 | 1422 | ||
| @@ -1404,6 +1426,16 @@ | |||
| 1404 | 1426 | self.actions.push(act); | |
| 1405 | 1427 | self | |
| 1406 | 1428 | } | |
| 1429 | + | ||
| 1430 | + | /// Where this cell's value goes. | |
| 1431 | + | /// | |
| 1432 | + | /// The value becomes the link. A cell with no value and an `activate` is a | |
| 1433 | + | /// link with nothing to press, so give it text. | |
| 1434 | + | #[must_use] | |
| 1435 | + | pub fn activate(mut self, action: Action) -> Self { | |
| 1436 | + | self.activate = Some(action); | |
| 1437 | + | self | |
| 1438 | + | } | |
| 1407 | 1439 | } | |
| 1408 | 1440 | ||
| 1409 | 1441 | impl From<String> for Cell { |
| @@ -620,6 +620,7 @@ | |||
| 620 | 620 | // click. A badge is not one and does not earn the filter. | |
| 621 | 621 | let carries_control = cells.values.iter().any(|cell| { | |
| 622 | 622 | !cell.actions.is_empty() | |
| 623 | + | || cell.activate.is_some() | |
| 623 | 624 | || cell | |
| 624 | 625 | .tokens | |
| 625 | 626 | .iter() | |
| @@ -643,7 +644,29 @@ | |||
| 643 | 644 | .values | |
| 644 | 645 | .iter() | |
| 645 | 646 | .map(|cell| { | |
| 646 | - | let mut inner = escape(&cell.value); | |
| 647 | + | // A value that is a link is the link, rather than text with a | |
| 648 | + | // control beside it. The row is a `div` and not an anchor even when | |
| 649 | + | // it activates, so this nests nothing: the href lands on the row | |
| 650 | + | // element as an attribute htmx reads, and the only `<a>` in the row | |
| 651 | + | // is the one a cell asked for. | |
| 652 | + | // | |
| 653 | + | // `data-act` for the same reason a button carries it. The row's | |
| 654 | + | // `ClickBeside` filter keys on that attribute, so without it a click | |
| 655 | + | // on the title would follow the link and open the row underneath. | |
| 656 | + | let mut inner = match &cell.activate { | |
| 657 | + | Some(action) => { | |
| 658 | + | let (open, close) = control_tag(action); | |
| 659 | + | let mut linked = String::from(open); | |
| 660 | + | class_attr(&["cell-activate"], opts, &mut linked); | |
| 661 | + | linked.push_str(" data-act"); | |
| 662 | + | action_attrs(action, Fires::Click, None, morphs, &mut linked); | |
| 663 | + | linked.push('>'); | |
| 664 | + | linked.push_str(&escape(&cell.value)); | |
| 665 | + | linked.push_str(close); | |
| 666 | + | linked | |
| 667 | + | } | |
| 668 | + | None => escape(&cell.value), | |
| 669 | + | }; | |
| 647 | 670 | // Tags after the text and before the controls, which is the | |
| 648 | 671 | // emphasis order a list row already falls in: the thing itself, | |
| 649 | 672 | // then what has a standing of its own, then what can be done to it. |
| @@ -731,6 +731,59 @@ | |||
| 731 | 731 | assert!(!html.contains("row-actions")); | |
| 732 | 732 | } | |
| 733 | 733 | ||
| 734 | + | #[test] | |
| 735 | + | fn a_cell_value_that_is_a_link_is_the_link() { | |
| 736 | + | // 35 cells across 18 of MNW's templates are a title that goes somewhere. | |
| 737 | + | // The value carries the address itself rather than growing an `Edit` button | |
| 738 | + | // beside it, which is what a title column looks like. | |
| 739 | + | let html = fragment(&Node::Table { | |
| 740 | + | columns: vec![ | |
| 741 | + | Column::new("Title").width(layout::Width::Fill), | |
| 742 | + | Column::new("Status").width(layout::Width::Content), | |
| 743 | + | ], | |
| 744 | + | rows: vec![Cells::new([ | |
| 745 | + | Cell::new("Release notes").activate(Action::get("/blog/7")), | |
| 746 | + | Cell::tag(Tag::badge("Published")), | |
| 747 | + | ])], | |
| 748 | + | }); | |
| 749 | + | ||
| 750 | + | // A read is an anchor with a real href, so middle-click and copy-link work | |
| 751 | + | // and the page is still navigable with JS off. | |
| 752 | + | assert!(html.contains("href=\"/blog/7\""), "{html}"); | |
| 753 | + | assert!(html.contains("cell-activate"), "{html}"); | |
| 754 | + | assert!(html.contains(">Release notes</a>"), "{html}"); | |
| 755 | + | ||
| 756 | + | // The value is still text, whatever the value happens to say. | |
| 757 | + | let hostile = fragment(&Node::Table { | |
| 758 | + | columns: vec![Column::new("Title").width(layout::Width::Fill)], | |
| 759 | + | rows: vec![Cells::new([ | |
| 760 | + | Cell::new("<img src=x onerror=alert(1)>").activate(Action::get("/blog/7")) | |
| 761 | + | ])], | |
| 762 | + | }); | |
| 763 | + | assert!(!hostile.contains("<img"), "{hostile}"); | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | #[test] | |
| 767 | + | fn a_linked_value_does_not_also_open_the_row() { | |
| 768 | + | // The same double-fire `Cell::acts` has, through a different member: a click | |
| 769 | + | // on the title would follow the link and swap the row's destination in | |
| 770 | + | // underneath it. The filter keys on `data-act`, so the link carries it. | |
| 771 | + | let html = fragment(&Node::Table { | |
| 772 | + | columns: vec![Column::new("Title").width(layout::Width::Fill)], | |
| 773 | + | rows: vec![ | |
| 774 | + | Cells::new([Cell::new("Release notes").activate(Action::get("/blog/7"))]) | |
| 775 | + | .activate(Action::get("/blog/7/edit")), | |
| 776 | + | ], | |
| 777 | + | }); | |
| 778 | + | ||
| 779 | + | assert!( | |
| 780 | + | html.contains("closest("), | |
| 781 | + | "the row filters the link out: {html}" | |
| 782 | + | ); | |
| 783 | + | assert!(html.contains("hx-get=\"/blog/7/edit\""), "{html}"); | |
| 784 | + | assert!(html.contains("hx-get=\"/blog/7\""), "{html}"); | |
| 785 | + | } | |
| 786 | + | ||
| 734 | 787 | #[test] | |
| 735 | 788 | fn a_control_beside_a_value_does_not_also_open_the_row() { | |
| 736 | 789 | // The five of MNW's thirty action-bearing rows that put a control next to a |