diff --git a/backend/druks/contrib/software_factory/issues/pages.py b/backend/druks/contrib/software_factory/issues/pages.py index 024b77fe..2e585e50 100644 --- a/backend/druks/contrib/software_factory/issues/pages.py +++ b/backend/druks/contrib/software_factory/issues/pages.py @@ -131,6 +131,10 @@ def _create_actions(projects: list[IssuesProject], accounts: list[Account]) -> l ] +def _ticket_link(ticket: Ticket, label: str) -> ui.Link: + return ui.Link(label, page="ticket", arguments={"identifier": ticket.identifier}) + + def _ticket_card(ticket: Ticket, account_names: dict[str, str]) -> ui.Card: description = [ticket.identifier] priority = Priority(ticket.priority) @@ -141,9 +145,7 @@ def _ticket_card(ticket: Ticket, account_names: dict[str, str]) -> ui.Card: return ui.Card( title=ticket.title, description=" · ".join(description), - controls=[ - ui.Link("Open", page="ticket", arguments={"identifier": ticket.identifier}), - ], + link=_ticket_link(ticket, ticket.title), ) @@ -156,13 +158,9 @@ def _ticket_row( [ ui.TextValue( ticket.identifier, - link=ui.Link( - ticket.identifier, - page="ticket", - arguments={"identifier": ticket.identifier}, - ), + link=_ticket_link(ticket, ticket.identifier), ), - ui.TextValue(ticket.title), + ui.TextValue(ticket.title, link=_ticket_link(ticket, ticket.title)), ui.TextValue(PRIORITY_LABELS[Priority(ticket.priority)]), ui.TextValue(_assignee_name(ticket.assignee_id, account_names)), ui.TextValue(project_names.get(ticket.project_id, "")), diff --git a/backend/druks/ui/blocks.py b/backend/druks/ui/blocks.py index 6a12f821..87dcfb6c 100644 --- a/backend/druks/ui/blocks.py +++ b/backend/druks/ui/blocks.py @@ -588,10 +588,14 @@ def __init__(self, blocks=(), **data): class Card(BlockParent): + """A titled panel. ``link`` is its destination. The shell makes the whole + panel the control when ``controls`` is empty.""" + block: Literal["card"] = "card" title: str = "" description: str = "" controls: list[Action | Link] = Field(default_factory=list) + link: Link | None = None def iter_actions(self) -> "Iterable[Action]": yield from super().iter_actions() @@ -602,6 +606,8 @@ def check_placement(self, *, followed: bool, regions: set[str], region: str = "" super().check_placement(followed=followed, regions=regions, region=region) for control in self.controls: control.check_placement(followed=followed, regions=regions, region=region) + if self.link: + self.link.check_placement(followed=followed, regions=regions, region=region) class Cards(PageBlock): diff --git a/backend/tests/software_factory/test_issues_pages.py b/backend/tests/software_factory/test_issues_pages.py index e349f24d..310bd2a6 100644 --- a/backend/tests/software_factory/test_issues_pages.py +++ b/backend/tests/software_factory/test_issues_pages.py @@ -76,7 +76,8 @@ async def test_created_ticket_lands_in_todo_on_board_and_list(druks_client): (card,) = _cards_in(by_title["Todo"]) assert card["title"] == "Ship the board" assert card["description"].startswith("DRU-1") - assert card["controls"][0]["arguments"] == {"identifier": ticket["identifier"]} + assert card["link"]["arguments"] == {"identifier": ticket["identifier"]} + assert card["controls"] == [] for title in BOARD_COLUMNS: if title != "Todo": assert _cards_in(by_title[title]) == [] @@ -87,6 +88,7 @@ async def test_created_ticket_lands_in_todo_on_board_and_list(druks_client): (row,) = by_section["Todo"]["rows"] assert row["cells"][0]["text"] == "DRU-1" assert row["cells"][1]["text"] == "Ship the board" + assert row["cells"][1]["link"]["arguments"] == {"identifier": ticket["identifier"]} for title in LIST_SECTIONS: if title != "Todo": assert by_section[title]["rows"] == [] diff --git a/docs/druks-ui.md b/docs/druks-ui.md index 83fcc5ff..bd1455bb 100644 --- a/docs/druks-ui.md +++ b/docs/druks-ui.md @@ -683,6 +683,7 @@ class Card: description: str = "" blocks: list[Block] = [] controls: list[Action | Link] = [] + link: Link | None = None ``` ```json @@ -691,10 +692,16 @@ class Card: "title": "peer-7", "description": "Last answered 4 minutes ago.", "blocks": [{"block": "text", "text": "Healthy."}], - "controls": [{"block": "link", "label": "Open", "page": "peer", "arguments": {"peer_id": "7"}, "url": ""}] + "controls": [], + "link": {"block": "link", "label": "peer-7", "page": "peer", "arguments": {"peer_id": "7"}, "url": ""} } ``` +`link` is the card's destination. With no `controls`, the shell makes the whole +panel the control. With `controls`, the title carries the link so a button is +not nested inside an anchor. A linked card should not hold other links in +`blocks`. + ### Cards ```python @@ -719,7 +726,14 @@ One card for each of a set of things. ```python ui.Cards( title="Peers", - cards=[ui.Card(title=peer.name, blocks=[...], controls=[...]) for peer in peers], + cards=[ + ui.Card( + title=peer.name, + blocks=[...], + link=ui.Link(peer.name, page="peer", arguments={"peer_id": str(peer.id)}), + ) + for peer in peers + ], empty=ui.EmptyState("No peer yet", controls=[ui.Link("Add one", page="new_peer")]), ) ``` @@ -1648,7 +1662,8 @@ action in `blocks` stays with the body content. `Page`, `Section`, `Card` and `EmptyState` all take `controls` the same way: a list of `Action` and `Link`, in the order the app wants them read. An `Action` calls one of the app's operations; a `Link` navigates. Both are things an -operator presses, so they share the row. +operator presses, so they share the row. A `Card` can also take `link`. That is +the card's destination, not a control on the row. ```python return ui.Page( diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c0a76a23..17bb4cd0 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -313,6 +313,7 @@ export interface CardBlock { description: string blocks: Block[] controls: (Action | Link)[] + link?: Link | null } export interface EmptyStateBlock { diff --git a/frontend/src/druksui/Blocks.test.tsx b/frontend/src/druksui/Blocks.test.tsx index 924de480..3538ef8a 100644 --- a/frontend/src/druksui/Blocks.test.tsx +++ b/frontend/src/druksui/Blocks.test.tsx @@ -207,4 +207,64 @@ describe('Cards', () => { expect(container.textContent).toBe('') }) + + it('makes a linked card the destination, with no Open control', () => { + renderBlocks([ + { + block: 'card', + title: 'Ship the board', + description: 'DRU-1', + blocks: [], + controls: [], + link: { + block: 'link', + label: 'Ship the board', + page: 'note', + arguments: { note_id: '7' }, + url: '', + subject: null, + }, + }, + ]) + + const card = screen.getByText('Ship the board').closest('a') + expect(card?.getAttribute('href')).toBe('/field_notes/notes/7') + expect(card?.className).toContain('dui-card') + expect(screen.queryByText('Open')).toBeNull() + }) + + it('puts the link on the title when the card also has controls', () => { + renderBlocks([ + { + block: 'card', + title: 'Ship the board', + description: 'DRU-1', + blocks: [], + controls: [ + { + block: 'link', + label: 'Archive', + page: 'notes', + arguments: {}, + url: '', + subject: null, + }, + ], + link: { + block: 'link', + label: 'Ship the board', + page: 'note', + arguments: { note_id: '7' }, + url: '', + subject: null, + }, + }, + ]) + + expect(screen.getByText('Ship the board').closest('a')?.getAttribute('href')).toBe( + '/field_notes/notes/7', + ) + expect(screen.getByText('Ship the board').closest('.dui-card')?.tagName).toBe('DIV') + expect(screen.getByText('Archive').getAttribute('href')).toBe('/field_notes') + }) }) diff --git a/frontend/src/druksui/Blocks.tsx b/frontend/src/druksui/Blocks.tsx index 570deea0..2ece3951 100644 --- a/frontend/src/druksui/Blocks.tsx +++ b/frontend/src/druksui/Blocks.tsx @@ -1,12 +1,13 @@ import { useContext } from 'react' +import { Link as RouteLink } from 'wouter' -import type { Action, Block, Link } from '../api/types' +import type { Action, Block, CardBlock, Link } from '../api/types' import { Markdown } from '../components/Markdown' import { GateControls } from './GateControls' import { Chart, Facts, ImageGallery, LinkControl, List, Metrics, Table } from './DataBlocks' import { ActionButton, Form } from './Form' import { Files, Image, Progress, Timeline } from './RunBlocks' -import { PagesContext, RegionContext } from './pages' +import { hrefForLink, PagesContext, RegionContext } from './pages' export function Blocks({ blocks }: { blocks: Block[] }) { return ( @@ -126,14 +127,7 @@ function BlockContent({ block }: { block: Block }) { ) case 'card': - return ( -