From 772a2f89e2bc8e9bca14118367afbe063a278e85 Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Tue, 8 Sep 2026 00:54:51 +0200 Subject: [PATCH] Make board cards the ticket destination. Card.link wraps the panel when there are no controls, so Open is no longer a second way onto the ticket. Co-authored-by: Cursor --- .../contrib/software_factory/issues/pages.py | 16 +++-- backend/druks/ui/blocks.py | 6 ++ .../software_factory/test_issues_pages.py | 4 +- docs/druks-ui.md | 21 ++++++- frontend/src/api/types.ts | 1 + frontend/src/druksui/Blocks.test.tsx | 60 +++++++++++++++++++ frontend/src/druksui/Blocks.tsx | 51 ++++++++++++---- frontend/src/druksui/DataBlocks.tsx | 18 +----- frontend/src/druksui/pages.ts | 10 +++- frontend/src/styles.css | 7 ++- 10 files changed, 154 insertions(+), 40 deletions(-) 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 ( -
- {block.title &&
{block.title}
} - {block.description &&
{block.description}
} - - -
- ) + return case 'cards': { const inside = block.cards.length ? (
    @@ -184,6 +178,43 @@ function BlockContent({ block }: { block: Block }) { } } +function CardPanel({ block }: { block: CardBlock }) { + const { app, pages } = useContext(PagesContext) + const wrapHref = block.link && !block.controls.length ? hrefForLink(block.link, app, pages) : '' + const title = block.title && ( + block.link && !wrapHref ? ( +
    + +
    + ) : ( +
    {block.title}
    + ) + ) + const inner = ( + <> + {title} + {block.description &&
    {block.description}
    } + + + + ) + if (wrapHref && block.link) { + if (block.link.url) { + return ( + + {inner} + + ) + } + return ( + + {inner} + + ) + } + return
    {inner}
    +} + export function Controls({ controls }: { controls: (Action | Link)[] }) { if (controls.length === 0) return null return ( diff --git a/frontend/src/druksui/DataBlocks.tsx b/frontend/src/druksui/DataBlocks.tsx index fcc2db22..7740a5be 100644 --- a/frontend/src/druksui/DataBlocks.tsx +++ b/frontend/src/druksui/DataBlocks.tsx @@ -13,7 +13,7 @@ import type { } from '../api/types' import { RelTime } from '../components/RelTime' import { Image, Status } from './RunBlocks' -import { fillPath, PagesContext } from './pages' +import { hrefForLink, PagesContext } from './pages' // The plot's own coordinates; CSS gives it its real size. const PLOT_WIDTH = 300 @@ -76,26 +76,14 @@ function TextDatum({ which shows the value's own text. */ export function LinkControl({ link, label = link.label }: { link: Link; label?: string }) { const { app, pages } = useContext(PagesContext) + const href = hrefForLink(link, app, pages) if (link.url) { return ( - + {label} ) } - if (link.subject) { - // The subject's own platform page — the full story of what druks did. - return ( - - {label} - - ) - } - const target = pages.find((entry) => entry.name === link.page) - const href = target ? fillPath(target.path, link.arguments) : '' if (href) { return ( diff --git a/frontend/src/druksui/pages.ts b/frontend/src/druksui/pages.ts index 74772328..8f3ad07b 100644 --- a/frontend/src/druksui/pages.ts +++ b/frontend/src/druksui/pages.ts @@ -1,7 +1,7 @@ import { createContext } from 'react' import type { SubjectTarget } from '../apps/registry' -import type { Block, Follows, Operation, PageEntry, PageSnapshot } from '../api/types' +import type { Block, Follows, Link, Operation, PageEntry, PageSnapshot } from '../api/types' // Which app's pages a block tree belongs to. A Link carries a page name, and // only this table turns that name into a URL — so the renderer reads it here @@ -35,6 +35,14 @@ export function fillPath(path: string, args: Record): string { return missing ? '' : filled } +/** Empty when the page name or an argument is missing. */ +export function hrefForLink(link: Link, app: string, pages: PageEntry[]): string { + if (link.url) return link.url + if (link.subject) return `/${app}/${link.subject.subjectType}/${link.subject.subjectId}` + const target = pages.find((entry) => entry.name === link.page) + return target ? fillPath(target.path, link.arguments) : '' +} + /** The tab strip a page belongs to: its family root first, then the root's * static children in declaration order. A child is static when the path it * adds to its parent carries no route parameter. No family, no tabs. */ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 304a24ee..9c944bb6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2035,6 +2035,11 @@ body[data-palette="mono"] .dui-page { .dui-card { border: 1px solid var(--border); border-radius: var(--dui-radius-surface); background: var(--surface); padding: 20px; margin: 12px 0; } .dui-card-title { font-size: 16px; line-height: 24px; font-weight: 600; color: var(--text); } .dui-card-desc { font-size: 13px; margin-top: 4px; line-height: 18px; } +.dui-card-title .dui-link { font-size: inherit; line-height: inherit; font-weight: inherit; color: inherit; min-height: 0; } +.dui-card-title .dui-link:hover { color: inherit; } +a.dui-card { color: inherit; text-decoration: none; cursor: pointer; } +a.dui-card:hover { border-color: var(--text-mid); } +a.dui-card:hover .dui-card-title { text-decoration: underline; text-underline-offset: 3px; } .dui-callout { border: 1px solid var(--border-loud); border-radius: var(--dui-radius-surface); padding: 14px 16px; font-size: 14px; line-height: 20px; max-width: 76ch; } .dui-callout-title { font-weight: 600; color: var(--text); margin-bottom: 3px; } @@ -2304,7 +2309,7 @@ body[data-palette="mono"] .dui-page { :is(.dui-action, .dui-link, .dui-tab, .dui-retry, .dui-parent, .dui-checkbox, .dui-choice input, .dui-input, .dui-gallery-item, .dui-dialog-close, - .dui-decision .review-btn):focus-visible { + .dui-decision .review-btn, a.dui-card):focus-visible { outline: 2px solid var(--accent-violet); outline-offset: 2px; }