diff --git a/backend/druks/contrib/software_factory/issues/pages.py b/backend/druks/contrib/software_factory/issues/pages.py index 83073254..65a78dde 100644 --- a/backend/druks/contrib/software_factory/issues/pages.py +++ b/backend/druks/contrib/software_factory/issues/pages.py @@ -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}, ) @@ -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 diff --git a/backend/druks/ui/blocks.py b/backend/druks/ui/blocks.py index 0370b492..63ddf5b9 100644 --- a/backend/druks/ui/blocks.py +++ b/backend/druks/ui/blocks.py @@ -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() @@ -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: diff --git a/backend/tests/software_factory/test_issues_pages.py b/backend/tests/software_factory/test_issues_pages.py index f4ab2b5e..d9b597d7 100644 --- a/backend/tests/software_factory/test_issues_pages.py +++ b/backend/tests/software_factory/test_issues_pages.py @@ -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): @@ -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": diff --git a/backend/tests/test_ui_data_blocks.py b/backend/tests/test_ui_data_blocks.py index caa34021..ce83b7f7 100644 --- a/backend/tests/test_ui_data_blocks.py +++ b/backend/tests/test_ui_data_blocks.py @@ -23,6 +23,7 @@ TableColumn, TableRow, Text, + TextField, TextValue, TimeValue, ) @@ -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 == {} diff --git a/docs/druks-ui.md b/docs/druks-ui.md index 39f8bc43..fa02b01f 100644 --- a/docs/druks-ui.md +++ b/docs/druks-ui.md @@ -710,6 +710,7 @@ class Card: blocks: list[Block] = [] controls: list[Action | Link] = [] link: Link | None = None + drag: dict = {} ``` ```json @@ -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": {} } ``` @@ -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 @@ -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 } ``` @@ -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. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 13082584..11042430 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -315,6 +315,7 @@ export interface CardBlock { blocks: Block[] controls: (Action | Link)[] link?: Link | null + drag?: Record } export interface EmptyStateBlock { @@ -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' diff --git a/frontend/src/druksui/Blocks.test.tsx b/frontend/src/druksui/Blocks.test.tsx index 3538ef8a..befed885 100644 --- a/frontend/src/druksui/Blocks.test.tsx +++ b/frontend/src/druksui/Blocks.test.tsx @@ -1,13 +1,31 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Router } from 'wouter' import { memoryLocation } from 'wouter/memory-location' -import type { Block, CardBlock, EmptyStateBlock, PageEntry } from '../api/types' +import { api } from '../api/client' +import type { Action, Block, CardBlock, EmptyStateBlock, Operation, PageEntry } from '../api/types' import { Blocks } from './Blocks' import { PagesContext } from './pages' -afterEach(cleanup) +vi.mock('../api/client', async () => { + const real = await vi.importActual('../api/client') + return { + ApiError: real.ApiError, + api: { callOperation: vi.fn(), readPage: vi.fn(), upload: vi.fn(), listApps: vi.fn() }, + } +}) + +const callOperation = vi.mocked(api.callOperation) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) +beforeEach(() => { + callOperation.mockResolvedValue() +}) const PAGES: PageEntry[] = [ { @@ -28,15 +46,25 @@ const PAGES: PageEntry[] = [ }, ] +const OPERATIONS: Operation[] = [ + { id: 'set_status', method: 'POST', path: '/api/software_factory/tickets/{identifier}/status' }, +] + function renderBlocks(blocks: Block[]) { - const { hook } = memoryLocation({ path: '/field_notes' }) - return render( - - - - - , + const location = memoryLocation({ path: '/field_notes', record: true }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const rendered = render( + + + + + + + , ) + return { ...rendered, location } } describe('the display core', () => { @@ -182,6 +210,53 @@ describe('Cards', () => { controls: [], } + function ticketCard(title: string, identifier: string): CardBlock { + return { + block: 'card', + title, + description: identifier, + blocks: [], + controls: [], + drag: { identifier }, + link: { + block: 'link', + label: title, + page: 'note', + arguments: { note_id: '7' }, + url: '', + subject: null, + }, + } + } + + function moveAction(status: string): Action { + return { + block: 'action', + label: `Move to ${status}`, + operation: 'set_status', + arguments: { status }, + fields: [], + tone: 'default', + confirm: '', + refresh: 'none', + link: null, + } + } + + function transfer() { + const data: Record = {} + return { + setData(type: string, value: string) { + data[type] = value + }, + getData(type: string) { + return data[type] ?? '' + }, + effectAllowed: 'move', + dropEffect: 'move', + } + } + it('shows one card for each thing, under the title', () => { const { container } = renderBlocks([ { block: 'cards', title: 'Peers', cards: [card('peer-7'), card('peer-9')], empty: null }, @@ -267,4 +342,198 @@ describe('Cards', () => { expect(screen.getByText('Ship the board').closest('.dui-card')?.tagName).toBe('DIV') expect(screen.getByText('Archive').getAttribute('href')).toBe('/field_notes') }) + + it('stacks cards in one column when layout is stack', () => { + const { container } = renderBlocks([ + { block: 'cards', title: 'Todo', layout: 'stack', cards: [card('one')], empty: null }, + ]) + + expect(container.querySelector('ul.dui-cards')?.className).toContain('dui-cards-stack') + }) + + it('posts the drop action with the card drag merged in', async () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + { + block: 'cards', + title: 'Done', + layout: 'stack', + drop: moveAction('done'), + cards: [], + empty: nothingYet, + }, + ]) + const dt = transfer() + const dest = container.querySelectorAll('.dui-cards-drop')[1]! + fireEvent.dragStart(container.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + fireEvent.dragOver(screen.getByText('No peer yet'), { dataTransfer: dt }) + fireEvent.drop(dest, { dataTransfer: dt }) + + await waitFor(() => expect(callOperation).toHaveBeenCalled()) + expect(callOperation).toHaveBeenCalledWith( + 'POST', + '/api/software_factory/tickets/BOX-1/status', + { status: 'done' }, + ) + }) + + it('does not post when the card lands on its own list', () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + ]) + const dt = transfer() + fireEvent.dragStart(container.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + fireEvent.drop(container.querySelector('.dui-cards-drop')!, { dataTransfer: dt }) + + expect(callOperation).not.toHaveBeenCalled() + }) + + it('does not follow the card link after a drag', () => { + const { container, location } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + ]) + const dt = transfer() + fireEvent.dragStart(container.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + fireEvent.click(screen.getByText('Ship')) + + expect(location.history).toEqual(['/field_notes']) + }) + + it('dims the card, then previews it in the list under the pointer', () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + { + block: 'cards', + title: 'Done', + layout: 'stack', + drop: moveAction('done'), + cards: [], + empty: nothingYet, + }, + ]) + const dt = transfer() + const drops = container.querySelectorAll('.dui-cards-drop') + const source = drops[0]! + const dest = drops[1]! + fireEvent.dragStart(source.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + + expect(source.querySelector('.dui-cards-item-dim')).toBeTruthy() + expect(source.className).toContain('dui-cards-drop-live') + expect(dest.className).toContain('dui-cards-drop-live') + expect(container.querySelector('.dui-card-ghost')).toBeNull() + expect(callOperation).not.toHaveBeenCalled() + + fireEvent.dragOver(dest, { dataTransfer: dt }) + + expect(source.querySelector('.dui-cards-item-away')).toBeTruthy() + expect(dest.querySelector('.dui-card-ghost')?.textContent).toContain('Ship') + expect(dest.className).toContain('dui-cards-drop-over') + expect(dest.querySelector('[hidden]')).toBeTruthy() + expect(callOperation).not.toHaveBeenCalled() + }) + + it('restores the card when the drag ends without a drop', () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + { + block: 'cards', + title: 'Done', + layout: 'stack', + drop: moveAction('done'), + cards: [], + empty: nothingYet, + }, + ]) + const dt = transfer() + const drops = container.querySelectorAll('.dui-cards-drop') + const source = drops[0]! + const dest = drops[1]! + const item = source.querySelector('ul.dui-cards li')! + fireEvent.dragStart(item, { dataTransfer: dt }) + fireEvent.dragOver(dest, { dataTransfer: dt }) + fireEvent.dragEnd(item, { dataTransfer: dt }) + + expect(container.querySelector('.dui-cards-item-dim')).toBeNull() + expect(container.querySelector('.dui-cards-item-away')).toBeNull() + expect(container.querySelector('.dui-card-ghost')).toBeNull() + expect(source.className).not.toContain('dui-cards-drop-live') + expect(screen.getByText('No peer yet')).toBeTruthy() + expect(callOperation).not.toHaveBeenCalled() + }) + + it('commits the list that held the placeholder, even if drop lands on a neighbor', async () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + { + block: 'cards', + title: 'Ready for Agent', + layout: 'stack', + drop: moveAction('ready_for_agent'), + cards: [], + empty: nothingYet, + }, + { + block: 'cards', + title: 'In Progress', + layout: 'stack', + drop: moveAction('in_progress'), + cards: [], + empty: nothingYet, + }, + ]) + const dt = transfer() + const drops = container.querySelectorAll('.dui-cards-drop') + fireEvent.dragStart(drops[0]!.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + fireEvent.dragOver(drops[1]!, { dataTransfer: dt }) + fireEvent.drop(drops[2]!, { dataTransfer: dt }) + + await waitFor(() => expect(callOperation).toHaveBeenCalled()) + expect(callOperation).toHaveBeenCalledWith( + 'POST', + '/api/software_factory/tickets/BOX-1/status', + { status: 'ready_for_agent' }, + ) + }) }) diff --git a/frontend/src/druksui/Blocks.tsx b/frontend/src/druksui/Blocks.tsx index afa2814b..afd25798 100644 --- a/frontend/src/druksui/Blocks.tsx +++ b/frontend/src/druksui/Blocks.tsx @@ -1,14 +1,75 @@ -import { useContext } from 'react' +import { createContext, useContext, useEffect, useId, useRef, useSyncExternalStore } from 'react' import { Link as RouteLink } from 'wouter' 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 { ActionButton, Form, useAction } from './Form' import { Files, Image, Progress, Timeline } from './RunBlocks' import { hrefForLink, PagesContext, RegionContext } from './pages' +const CardsZoneContext = createContext('') + +type CardsDrag = { + source: string + over: string + card: CardBlock + payload: Record + accept: (payload: Record) => void +} + +let cardsDrag: CardsDrag | null = null +const cardsDragListeners = new Set<() => void>() + +function subscribeCardsDrag(listener: () => void) { + cardsDragListeners.add(listener) + return () => { + cardsDragListeners.delete(listener) + } +} + +function onWindowDragOver(event: DragEvent) { + if (!cardsDrag) return + const node = event.target instanceof Element ? event.target.closest('[data-cards-zone]') : null + if (node || cardsDrag.over === cardsDrag.source) return + setCardsDrag({ ...cardsDrag, over: cardsDrag.source }) +} + +function finishCardsDrop(raw: string) { + const current = cardsDrag + setCardsDrag(null) + if (!current || !raw || current.over === current.source) return + current.accept(JSON.parse(raw) as Record) +} + +function setCardsDrag(next: CardsDrag | null) { + if ( + cardsDrag === next || + (cardsDrag && + next && + cardsDrag.source === next.source && + cardsDrag.over === next.over && + cardsDrag.card === next.card) + ) { + return + } + const started = !cardsDrag && next + const ended = cardsDrag && !next + cardsDrag = next + if (started) window.addEventListener('dragover', onWindowDragOver) + if (ended) window.removeEventListener('dragover', onWindowDragOver) + cardsDragListeners.forEach((listener) => listener()) +} + +function useCardsDrag() { + return useSyncExternalStore(subscribeCardsDrag, () => cardsDrag) +} + +function isDragged(card: CardBlock, drag: CardsDrag) { + return JSON.stringify(card.drag ?? {}) === JSON.stringify(drag.payload) +} + export function Blocks({ blocks }: { blocks: Block[] }) { return ( <> @@ -130,26 +191,9 @@ function BlockContent({ block }: { block: Block }) { ) case 'card': return - case 'cards': { - const inside = block.cards.length ? ( -
    - {block.cards.map((card, index) => ( -
  • - -
  • - ))} -
- ) : ( - block.empty && - ) - if (!inside) return null - return ( -
- {block.title &&

{block.title}

} - {inside} -
- ) - } + case 'cards': + if (!block.drop) return + return case 'section': { const decision = block.blocks.some((insideBlock) => insideBlock.block === 'gate_controls') return ( @@ -203,13 +247,13 @@ function CardPanel({ block }: { block: CardBlock }) { if (wrapHref && block.link) { if (block.link.url) { return ( - + {inner} ) } return ( - + {inner} ) @@ -217,6 +261,163 @@ function CardPanel({ block }: { block: CardBlock }) { return
{inner}
} +function cardsClass(layout: 'wrap' | 'stack' | undefined) { + return `dui-cards${layout === 'stack' ? ' dui-cards-stack' : ''}` +} + +function CardsStatic({ + block, +}: { + block: Extract +}) { + const inside = block.cards.length ? ( +
    + {block.cards.map((card, index) => ( +
  • + +
  • + ))} +
+ ) : ( + block.empty && + ) + if (!inside) return null + return ( +
+ {block.title &&

{block.title}

} + {inside} +
+ ) +} + +function CardItem({ card }: { card: CardBlock }) { + const zone = useContext(CardsZoneContext) + const drag = useCardsDrag() + const skipClick = useRef(false) + const payload = card.drag ?? {} + const movable = Boolean(zone && Object.keys(payload).length) + const dragged = Boolean(drag && drag.source === zone && isDragged(card, drag)) + const away = Boolean(dragged && drag && drag.over !== zone) + return ( +
  • { + skipClick.current = true + event.dataTransfer.setData('application/json', JSON.stringify(payload)) + event.dataTransfer.setData('text/x-druks-zone', zone) + setCardsDrag({ + source: zone, + over: zone, + card, + payload, + accept: () => {}, + }) + } + : undefined + } + onDragEnd={() => setCardsDrag(null)} + onClickCapture={ + movable + ? (event) => { + if (!skipClick.current) return + event.preventDefault() + event.stopPropagation() + skipClick.current = false + } + : undefined + } + > + +
  • + ) +} + +function CardGhost({ card }: { card: CardBlock }) { + return ( + + ) +} + +function CardsDrop({ + block, + drop, +}: { + block: Extract + drop: Action +}) { + const run = useAction(drop) + const zone = useId() + const drag = useCardsDrag() + const hovering = drag?.over === zone + const holding = Boolean(drag && hovering && drag.source !== zone) + const inside = ( + <> + {block.cards.length || holding ? ( +
      + {block.cards.map((card, index) => ( + + ))} + {holding && drag ? : null} +
    + ) : null} + {block.empty && !block.cards.length ? ( + + ) : null} + + ) + useEffect(() => { + return () => { + if (cardsDrag?.source === zone) setCardsDrag(null) + } + }, [zone]) + return ( + +
    event.preventDefault()} + onDragOver={(event) => { + event.preventDefault() + if (!cardsDrag || cardsDrag.over === zone) return + setCardsDrag({ + ...cardsDrag, + over: zone, + accept: (payload) => { + void run.call(payload) + }, + }) + }} + onDrop={(event) => { + event.preventDefault() + event.stopPropagation() + finishCardsDrop(event.dataTransfer.getData('application/json')) + }} + > +
    + {block.title &&

    {block.title}

    } + {inside} +
    + {run.problem ? ( +
    + {run.problem} +
    + ) : null} +
    +
    + ) +} + export function Controls({ controls }: { controls: (Action | Link)[] }) { if (controls.length === 0) return null return ( diff --git a/frontend/src/druksui/Form.tsx b/frontend/src/druksui/Form.tsx index 6d223b8c..3b9b5c1c 100644 --- a/frontend/src/druksui/Form.tsx +++ b/frontend/src/druksui/Form.tsx @@ -319,7 +319,8 @@ function Confirm({ action, run }: { action: Action; run: ReturnType void) { +// eslint-disable-next-line react-refresh/only-export-components -- drop zones run the same action hook +export function useAction(action: Action, fields: Field[] = [], clear?: () => void) { const fieldNames = fields.map((one) => one.name) const secretNames = fields.filter((one) => one.field === 'secret').map((one) => one.name) const [pending, setPending] = useState(false) diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6942583a..64efb804 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2197,8 +2197,24 @@ a.dui-card:hover .dui-card-title { text-decoration: underline; text-underline-of .dui-chart-data { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } -.dui-cards { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin: 16px 0; } +.dui-cards { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin: 16px 0; position: relative; } +.dui-cards-stack { display: flex; flex-direction: column; } +/* A board column stretches to the tallest neighbor. The drop target has to + fill that height, or an empty column only accepts a drop on its empty copy. + min-width: 0 keeps columns from growing with empty copy, which would shove + the next column under the pointer when the ghost replaces it. */ +.dui-column > .dui-section { flex: 1; min-width: 0; } +.dui-cards-drop { min-height: 48px; flex: 1; display: flex; flex-direction: column; min-width: 0; } +.dui-cards-drop .dui-cards-block { flex: 1; display: flex; flex-direction: column; min-width: 0; } +.dui-cards-drop .dui-cards, +.dui-cards-drop .dui-empty { flex: 1; } +.dui-cards-drop-live { outline: 1px dashed var(--border-loud); outline-offset: 4px; border-radius: var(--dui-radius-surface); } +.dui-cards-drop-over { outline-color: var(--accent); background: color-mix(in oklch, var(--accent) 8%, transparent); } +.dui-cards [draggable='true'] { cursor: grab; } .dui-cards .dui-card { margin: 0; } +.dui-cards-item-dim { opacity: 0.4; } +.dui-cards-item-away { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; } +.dui-card-ghost { opacity: 0.4; pointer-events: none; } .dui-gallery-grid { list-style: none; margin: 12px 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; } .dui-gallery-item { display: block; } .dui-gallery .dui-image { margin: 0; }