Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions backend/druks/contrib/software_factory/issues/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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),
)


Expand All @@ -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, "")),
Expand Down
6 changes: 6 additions & 0 deletions backend/druks/ui/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion backend/tests/software_factory/test_issues_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]) == []
Expand All @@ -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"] == []
Expand Down
21 changes: 18 additions & 3 deletions docs/druks-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@ class Card:
description: str = ""
blocks: list[Block] = []
controls: list[Action | Link] = []
link: Link | None = None
```

```json
Expand All @@ -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
Expand All @@ -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")]),
)
```
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ export interface CardBlock {
description: string
blocks: Block[]
controls: (Action | Link)[]
link?: Link | null
}

export interface EmptyStateBlock {
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/druksui/Blocks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
51 changes: 41 additions & 10 deletions frontend/src/druksui/Blocks.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -126,14 +127,7 @@ function BlockContent({ block }: { block: Block }) {
</div>
)
case 'card':
return (
<div className="dui-card">
{block.title && <div className="dui-card-title">{block.title}</div>}
{block.description && <div className="dui-card-desc dim">{block.description}</div>}
<Blocks blocks={block.blocks} />
<Controls controls={block.controls} />
</div>
)
return <CardPanel block={block} />
case 'cards': {
const inside = block.cards.length ? (
<ul className="dui-cards">
Expand Down Expand Up @@ -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 ? (
<div className="dui-card-title">
<LinkControl link={block.link} label={block.title} />
</div>
) : (
<div className="dui-card-title">{block.title}</div>
)
)
const inner = (
<>
{title}
{block.description && <div className="dui-card-desc dim">{block.description}</div>}
<Blocks blocks={block.blocks} />
<Controls controls={block.controls} />
</>
)
if (wrapHref && block.link) {
if (block.link.url) {
return (
<a className="dui-card" href={wrapHref} target="_blank" rel="noreferrer">
{inner}
</a>
)
}
return (
<RouteLink href={wrapHref} className="dui-card">
{inner}
</RouteLink>
)
}
return <div className="dui-card">{inner}</div>
}

export function Controls({ controls }: { controls: (Action | Link)[] }) {
if (controls.length === 0) return null
return (
Expand Down
18 changes: 3 additions & 15 deletions frontend/src/druksui/DataBlocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<a className="dui-link" href={link.url} target="_blank" rel="noreferrer">
<a className="dui-link" href={href} target="_blank" rel="noreferrer">
{label}
</a>
)
}
if (link.subject) {
// The subject's own platform page — the full story of what druks did.
return (
<RouteLink
href={`/${app}/${link.subject.subjectType}/${link.subject.subjectId}`}
className="dui-link"
>
{label}
</RouteLink>
)
}
const target = pages.find((entry) => entry.name === link.page)
const href = target ? fillPath(target.path, link.arguments) : ''
if (href) {
return (
<RouteLink href={href} className="dui-link">
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/druksui/pages.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -35,6 +35,14 @@ export function fillPath(path: string, args: Record<string, string>): 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. */
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
}
Expand Down