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
8 changes: 8 additions & 0 deletions backend/druks/contrib/software_factory/issues/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def _ticket_card(ticket: Ticket, account_names: dict[str, str]) -> ui.Card:
title=ticket.title,
description=" · ".join(description),
link=_ticket_link(ticket, ticket.title),
drag={"identifier": ticket.identifier},
)


Expand Down Expand Up @@ -378,6 +379,13 @@ async def board(
title=item.label,
blocks=[
ui.Cards(
layout="stack",
drop=ui.Action(
label=f"Move to {item.label}",
operation="set_status",
arguments={"status": item.value},
refresh="page",
),
cards=[
_ticket_card(ticket, account_names)
for ticket in tickets
Expand Down
21 changes: 18 additions & 3 deletions backend/druks/ui/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,13 +602,15 @@ 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."""
panel the control when ``controls`` is empty. ``drag`` is what a drop
action receives; empty means the card does not move."""

block: Literal["card"] = "card"
title: str = ""
description: str = ""
controls: list[Action | Link] = Field(default_factory=list)
link: Link | None = None
drag: dict[str, Any] = Field(default_factory=dict)

def iter_actions(self) -> "Iterable[Action]":
yield from super().iter_actions()
Expand All @@ -624,21 +626,34 @@ def check_placement(self, *, followed: bool, regions: set[str], region: str = ""


class Cards(PageBlock):
"""One card for each of a set of things. The shell arranges them, so a page
that wants a particular geometry reaches for ``Columns`` instead."""
"""One card for each of a set of things. ``wrap`` lets the shell fit as
many across as the screen takes. ``stack`` is one column. ``drop`` is the
action a dragged card submits onto this list."""

block: Literal["cards"] = "cards"
title: str = ""
cards: list[Card] = Field(default_factory=list)
empty: EmptyState | None = None
layout: Literal["wrap", "stack"] = "wrap"
drop: Action | None = None

@model_validator(mode="after")
def _drop_is_immediate(self) -> "Cards":
if self.drop and (self.drop.fields or self.drop.confirm):
raise ValueError("Cards.drop cannot collect fields or confirm — the drop is the submit")
return self

def iter_actions(self) -> "Iterable[Action]":
if self.drop:
yield from self.drop.iter_actions()
for card in self.cards:
yield from card.iter_actions()
if self.empty:
yield from self.empty.iter_actions()

def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None:
if self.drop:
self.drop.check_placement(followed=followed, regions=regions, region=region)
for card in self.cards:
card.check_placement(followed=followed, regions=regions, region=region)
if self.empty:
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/software_factory/test_issues_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,19 @@ async def test_empty_board_shows_columns_and_create_actions(druks_client):
assert [column["title"] for column in columns] == BOARD_COLUMNS
for column in columns:
cards = column["blocks"][0]
assert cards["layout"] == "stack"
assert cards["drop"]["operation"] == "set_status"
assert cards["drop"]["refresh"] == "page"
assert cards["cards"] == []
assert cards["empty"]["title"] == "Nothing here"
assert [column["blocks"][0]["drop"]["arguments"]["status"] for column in columns] == [
"backlog",
"todo",
"ready_for_agent",
"in_progress",
"in_review",
"done",
]


async def test_created_ticket_lands_in_todo_on_board_and_issues(druks_client):
Expand All @@ -110,6 +121,7 @@ async def test_created_ticket_lands_in_todo_on_board_and_issues(druks_client):
assert card["title"] == "Ship the board"
assert card["description"].startswith("DRU-1")
assert card["link"]["arguments"] == {"identifier": ticket["identifier"]}
assert card["drag"] == {"identifier": ticket["identifier"]}
assert card["controls"] == []
for title in BOARD_COLUMNS:
if title != "Todo":
Expand Down
43 changes: 42 additions & 1 deletion backend/tests/test_ui_data_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
TableColumn,
TableRow,
Text,
TextField,
TextValue,
TimeValue,
)
Expand Down Expand Up @@ -162,10 +163,50 @@ def test_cards_finds_an_action_in_a_card_and_in_its_empty_state():
block = Cards(
cards=[Card(title="Peer 7", controls=[Action(label="Retire", operation="retire_peer")])],
empty=EmptyState("No peer yet", controls=[Action(label="Scan", operation="scan")]),
drop=Action(label="Move", operation="move_peer"),
)

assert [action.operation for action in block.iter_actions()] == ["retire_peer", "scan"]
assert [action.operation for action in block.iter_actions()] == [
"move_peer",
"retire_peer",
"scan",
]


def test_cards_drop_cannot_collect_fields_or_confirm():
with pytest.raises(ValueError, match="drop is the submit"):
Cards(
drop=Action(
label="Move",
operation="move_peer",
fields=[TextField(name="reason", label="Reason")],
)
)
with pytest.raises(ValueError, match="drop is the submit"):
Cards(drop=Action(label="Move", operation="move_peer", confirm="Move this peer?"))


def test_cards_carries_stack_layout_drop_and_card_drag():
(block,) = wire(
Cards(
layout="stack",
drop=Action(
label="Move",
operation="move_peer",
arguments={"status": "todo"},
),
cards=[Card(title="peer-7", drag={"identifier": "P-7"})],
)
)

assert block["layout"] == "stack"
assert block["drop"]["operation"] == "move_peer"
assert block["drop"]["arguments"] == {"status": "todo"}
assert block["cards"][0]["drag"] == {"identifier": "P-7"}


def test_cards_with_none_and_nothing_to_say_carries_no_empty_state():
assert Cards().empty is None
assert Cards().layout == "wrap"
assert Cards().drop is None
assert Card().drag == {}
29 changes: 23 additions & 6 deletions docs/druks-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ class Card:
blocks: list[Block] = []
controls: list[Action | Link] = []
link: Link | None = None
drag: dict = {}
```

```json
Expand All @@ -719,7 +720,8 @@ class Card:
"description": "Last answered 4 minutes ago.",
"blocks": [{"block": "text", "text": "Healthy."}],
"controls": [],
"link": {"block": "link", "label": "peer-7", "page": "peer", "arguments": {"peer_id": "7"}, "url": ""}
"link": {"block": "link", "label": "peer-7", "page": "peer", "arguments": {"peer_id": "7"}, "url": ""},
"drag": {}
}
```

Expand All @@ -728,6 +730,9 @@ 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`.

`drag` is what a [`Cards.drop`](#cards) action receives. Empty means the card
does not move.

### Cards

```python
Expand All @@ -736,14 +741,18 @@ class Cards:
title: str = ""
cards: list[Card] = []
empty: EmptyState | None = None
layout: Literal["wrap", "stack"] = "wrap"
drop: Action | None = None
```

```json
{
"block": "cards",
"title": "Peers",
"cards": [{"block": "card", "title": "peer-7", "description": "", "blocks": [], "controls": []}],
"empty": null
"cards": [{"block": "card", "title": "peer-7", "description": "", "blocks": [], "controls": [], "drag": {}}],
"empty": null,
"layout": "wrap",
"drop": null
}
```

Expand All @@ -764,11 +773,19 @@ ui.Cards(
)
```

The shell arranges the cards. It fits as many across as the screen takes, so
`Cards` sets no geometry of its own.
`wrap` (the default) fits as many cards across as the screen takes. `stack`
is one column, for a board of statuses.

`drop` is the action a dragged card submits onto this list. The shell merges
the card's `drag` into the action arguments and runs the operation. The drop
is the submit: `drop` cannot set `fields` or `confirm`. A drop onto the same
list does nothing. Clicking a linked card still opens it. While a card is
dragged, the shell dims it and shows a placeholder in the list under the
pointer. The action runs only on drop.

With no cards, the shell shows the title and `empty` in their place. With no
cards and no `empty`, it shows nothing. `Table` reads the same way.
cards and no `empty`, it shows nothing unless `drop` is set, so an empty
column can still receive a card. `Table` reads the same way for `empty`.

`empty` takes an `EmptyState`, not a line of text, because an empty page
usually has to say what to do next.
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ export interface CardBlock {
blocks: Block[]
controls: (Action | Link)[]
link?: Link | null
drag?: Record<string, unknown>
}

export interface EmptyStateBlock {
Expand Down Expand Up @@ -455,7 +456,14 @@ export type Block =
layout?: 'stack' | 'prose' | 'row'
}
| CardBlock
| { block: 'cards'; title: string; cards: CardBlock[]; empty: EmptyStateBlock | null }
| {
block: 'cards'
title: string
cards: CardBlock[]
empty: EmptyStateBlock | null
layout?: 'wrap' | 'stack'
drop?: Action | null
}
| {
block: 'callout'
tone: 'info' | 'success' | 'warning' | 'danger'
Expand Down
Loading