diff --git a/backend/druks/contrib/software_factory/issues/pages.py b/backend/druks/contrib/software_factory/issues/pages.py new file mode 100644 index 00000000..024b77fe --- /dev/null +++ b/backend/druks/contrib/software_factory/issues/pages.py @@ -0,0 +1,380 @@ +from druks import ui +from druks.accounts.models import Account +from druks.contrib.software_factory.issues.enums import Priority, Status +from druks.contrib.software_factory.issues.models import Comment, IssuesProject, Ticket + +# The board's columns, worked-on left to right. Cancelled is not a column: a +# cancelled ticket is off the board, which is what ``Ticket.list_board`` reads. +BOARD_STATUSES = ( + Status.BACKLOG, + Status.TODO, + Status.READY_FOR_AGENT, + Status.IN_PROGRESS, + Status.IN_REVIEW, + Status.DONE, +) +# The list's sections, worked-on first, with the finished ones at the bottom. +LIST_STATUSES = ( + Status.IN_PROGRESS, + Status.IN_REVIEW, + Status.READY_FOR_AGENT, + Status.TODO, + Status.BACKLOG, + Status.DONE, + Status.CANCELLED, +) + +# The words the screens spell a priority with. The stored value stays +# snake_case; only these strings change when the board wants different words. +PRIORITY_LABELS: dict[Priority, str] = { + Priority.NONE: "No priority", + Priority.URGENT: "Urgent", + Priority.HIGH: "High", + Priority.MEDIUM: "Medium", + Priority.LOW: "Low", +} +# How a status reads as a chip. Presentation only — the workflow is the enum. +STATUS_TONES: dict[Status, str] = { + Status.BACKLOG: "neutral", + Status.TODO: "neutral", + Status.READY_FOR_AGENT: "warning", + Status.IN_PROGRESS: "active", + Status.IN_REVIEW: "active", + Status.DONE: "success", + Status.CANCELLED: "danger", +} + +UNASSIGNED = "Unassigned" +# An account that has since gone, or druks' own system actor: the row still +# reads, it just carries no name. +UNATTRIBUTED = "Unattributed" + + +def _project_options(projects: list[IssuesProject]) -> list[ui.Option]: + """Every namespace a ticket can be minted into. No blank entry: a ticket + without a project could not be named.""" + return [ui.Option(project.name, value=str(project.id)) for project in projects] + + +def _assignee_options(accounts: list[Account]) -> list[ui.Option]: + """Who work can be handed to, plus nobody. Unassigned carries the empty + value the doors read back as "no assignee".""" + return [ui.Option(UNASSIGNED, value="")] + [ + ui.Option(account.username, value=account.id) for account in accounts + ] + + +def _priority_options() -> list[ui.Option]: + return [ui.Option(label, value=priority.value) for priority, label in PRIORITY_LABELS.items()] + + +def _status_options() -> list[ui.Option]: + return [ui.Option(status.label, value=status.value) for status in Status] + + +def _assignee_name(assignee_id: str | None, account_names: dict[str, str]) -> str: + if not assignee_id: + return UNASSIGNED + return account_names.get(assignee_id, UNATTRIBUTED) + + +def _create_actions(projects: list[IssuesProject], accounts: list[Account]) -> list[ui.Action]: + """Creation is a control on the board and the list, not a destination: a + page that lists nothing is not where a ticket gets written.""" + return [ + ui.Action( + label="New ticket", + operation="create_ticket", + tone="primary", + fields=[ + ui.TextField(name="title", label="Title", is_required=True), + ui.SelectField( + name="project_id", + label="Project", + options=_project_options(projects), + is_required=True, + help_text="The namespace the identifier is minted from.", + ), + ui.TextAreaField(name="description", label="Description"), + ui.SelectField( + name="status", + label="Status", + options=_status_options(), + value=Status.TODO.value, + ), + ui.SelectField( + name="priority", + label="Priority", + options=_priority_options(), + value=Priority.NONE.value, + ), + ui.SelectField( + name="assignee_id", + label="Assignee", + options=_assignee_options(accounts), + ), + ], + ), + ui.Action( + label="New project", + operation="create_ticket_project", + fields=[ + ui.TextField(name="name", label="Name", is_required=True), + ui.TextField( + name="prefix", + label="Prefix", + is_required=True, + help_text="2-6 letters, A-Z — the first half of every identifier it mints.", + ), + ], + ), + ] + + +def _ticket_card(ticket: Ticket, account_names: dict[str, str]) -> ui.Card: + description = [ticket.identifier] + priority = Priority(ticket.priority) + if priority is not Priority.NONE: + description.append(PRIORITY_LABELS[priority]) + if ticket.assignee_id: + description.append(_assignee_name(ticket.assignee_id, account_names)) + return ui.Card( + title=ticket.title, + description=" · ".join(description), + controls=[ + ui.Link("Open", page="ticket", arguments={"identifier": ticket.identifier}), + ], + ) + + +def _ticket_row( + ticket: Ticket, + project_names: dict[int, str], + account_names: dict[str, str], +) -> ui.TableRow: + return ui.TableRow( + [ + ui.TextValue( + ticket.identifier, + link=ui.Link( + ticket.identifier, + page="ticket", + arguments={"identifier": ticket.identifier}, + ), + ), + ui.TextValue(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, "")), + ui.TimeValue(ticket.updated_at), + ] + ) + + +def _comment_blocks(comments: list[Comment], account_names: dict[str, str]) -> list[ui.Card]: + return [ + ui.Card( + title=account_names.get(comment.author_id, UNATTRIBUTED), + description=comment.created_at.isoformat(sep=" ", timespec="minutes"), + blocks=[ui.Markdown(comment.body)], + ) + for comment in comments + ] + + +@ui.page("/board") +async def board(): + tickets = await Ticket.list_board() + projects = await IssuesProject.list() + accounts = await Account.list_all() + account_names = {account.id: account.username for account in accounts} + return ui.Page( + "Board", + description="What this install is working on, a column to a status.", + # Built from the projects and accounts alone, so an empty install still + # offers both: the board is where a first ticket gets written. + controls=_create_actions(projects, accounts), + blocks=[ + ui.Columns( + [ + ui.Section( + title=status.label, + blocks=[ + ui.Cards( + cards=[ + _ticket_card(ticket, account_names) + for ticket in tickets + if ticket.status == status + ], + empty=ui.EmptyState( + "Nothing here", + description=f"No ticket is in {status.label}.", + ), + ) + ], + ) + for status in BOARD_STATUSES + ] + ) + ], + ) + + +@ui.page("/tickets/{identifier}") +async def ticket(identifier: str): + found = await Ticket.get_for_identifier(identifier) + if not found: + return ui.Page( + identifier, + blocks=[ + ui.EmptyState( + "No such ticket", + description=f"Nothing on this board is named {identifier}.", + controls=[ui.Link("Board", page="board")], + ) + ], + ) + + projects = await IssuesProject.list() + accounts = await Account.list_all() + project_names = {project.id: project.name for project in projects} + account_names = {account.id: account.username for account in accounts} + status = Status(found.status) + comments = await found.list_comments() + thread = _comment_blocks(comments, account_names) or [ + ui.EmptyState("No comments yet", description="Say something about this ticket.") + ] + + return ui.Page( + found.title, + description=found.identifier, + # The whole page follows the ticket, so a status write from anywhere — + # Software Factory included — redraws it without a navigation. + follows=found, + controls=[ + ui.Action( + label="Move", + operation="set_status", + arguments={"identifier": found.identifier}, + fields=[ + ui.SelectField( + name="status", + label="Status", + options=_status_options(), + value=status.value, + is_required=True, + ) + ], + ) + ], + blocks=[ + ui.Markdown(found.description or "_No description._"), + ui.Facts( + [ + ui.Fact( + "Status", value=ui.StatusValue(status.label, tone=STATUS_TONES[status]) + ), + ui.Fact( + "Priority", + value=ui.TextValue(PRIORITY_LABELS[Priority(found.priority)]), + ), + ui.Fact( + "Assignee", + value=ui.TextValue(_assignee_name(found.assignee_id, account_names)), + ), + ui.Fact( + "Project", + value=ui.TextValue(project_names.get(found.project_id, "")), + ), + ui.Fact("Identifier", value=ui.TextValue(found.identifier)), + ], + title="Details", + ), + ui.Form( + title="Edit", + fields=[ + ui.TextField(name="title", label="Title", value=found.title, is_required=True), + ui.TextAreaField( + name="description", label="Description", value=found.description + ), + ui.SelectField( + name="priority", + label="Priority", + options=_priority_options(), + value=found.priority, + ), + ui.SelectField( + name="assignee_id", + label="Assignee", + options=_assignee_options(accounts), + value=found.assignee_id or "", + ), + ui.SelectField( + name="project_id", + label="Project", + options=_project_options(projects), + value=str(found.project_id), + ), + ], + action=ui.Action( + label="Save", + operation="update_ticket", + arguments={"identifier": found.identifier}, + ), + ), + ui.Section( + title="Comments", + # Named, so the comment below replaces this section alone and + # the thread grows in place. + name="comments", + blocks=[ + *thread, + ui.Form( + title="Add a comment", + fields=[ui.TextAreaField(name="body", label="Comment", is_required=True)], + action=ui.Action( + label="Comment", + operation="add_comment", + arguments={"identifier": found.identifier}, + tone="primary", + refresh="region", + ), + ), + ], + ), + ], + ) + + +# Declared last: the name is the page's name, and binding it shadows the +# builtin for the rest of the module. +@ui.page("/list") +async def list(): + projects = await IssuesProject.list() + accounts = await Account.list_all() + project_names = {project.id: project.name for project in projects} + account_names = {account.id: account.username for account in accounts} + sections = [] + for status in LIST_STATUSES: + rows = await Ticket.list_for_status(status) + sections.append( + ui.Table( + title=status.label, + columns=[ + ui.TableColumn("Identifier"), + ui.TableColumn("Title"), + ui.TableColumn("Priority"), + ui.TableColumn("Assignee"), + ui.TableColumn("Project"), + ui.TableColumn("Updated", align="end"), + ], + rows=[_ticket_row(row, project_names, account_names) for row in rows], + empty_text=f"No ticket is in {status.label}.", + ) + ) + return ui.Page( + "List", + description="Every ticket, worked-on first and cancelled last.", + controls=_create_actions(projects, accounts), + blocks=[ui.Stack(sections)], + ) diff --git a/backend/druks/ui/exceptions.py b/backend/druks/ui/exceptions.py index 335d93ee..ef4d8126 100644 --- a/backend/druks/ui/exceptions.py +++ b/backend/druks/ui/exceptions.py @@ -1,6 +1,6 @@ class PageRouteError(Exception): """An app's pages cannot make a route table. Raised at declaration for a - nested child, and at boot for a missing landing page, a repeated page name, + nested child, and at boot for two landing pages, a repeated page name, two routes a request cannot tell apart, a signature that does not match its route, or a navigation entry that is not a static top-level page.""" diff --git a/backend/druks/ui/page.py b/backend/druks/ui/page.py index 68071716..686dbb39 100644 --- a/backend/druks/ui/page.py +++ b/backend/druks/ui/page.py @@ -164,11 +164,12 @@ def list_pages_for_app(app_name: str, package: str) -> list[PageRoute]: return [] landing = [page_route for page_route in declared if page_route.route == "/"] - if len(landing) != 1: + if len(landing) > 1: named = sorted(page_route.name for page_route in landing) raise PageRouteError( f"app {app_name!r} declares {len(landing)} pages at '/' ({named}). Declare " - "exactly one: it is the page the app opens on." + "at most one: it is the page the app opens on. An app whose shell home is " + "a React route may declare none." ) by_name: dict[str, PageRoute] = {} diff --git a/backend/tests/software_factory/test_issues_pages.py b/backend/tests/software_factory/test_issues_pages.py new file mode 100644 index 00000000..e349f24d --- /dev/null +++ b/backend/tests/software_factory/test_issues_pages.py @@ -0,0 +1,176 @@ +from druks.contrib.software_factory.issues.models import Ticket + +BOARD_COLUMNS = [ + "Backlog", + "Todo", + "Ready for Agent", + "In Progress", + "In Review", + "Done", +] +LIST_SECTIONS = [ + "In Progress", + "In Review", + "Ready for Agent", + "Todo", + "Backlog", + "Done", + "Cancelled", +] + +_PAGES = "/api/software_factory/pages" +_TICKETS = "/api/software_factory/tickets" +_PROJECTS = "/api/software_factory/ticket-projects" + + +async def _open_project(druks_client, *, name="druks", prefix="dru"): + created = await druks_client.post(_PROJECTS, json={"name": name, "prefix": prefix}) + assert created.status_code == 201 + return created.json() + + +async def _open_ticket(druks_client, project_id, **fields): + created = await druks_client.post( + _TICKETS, + json={"title": "one", "project_id": project_id, **fields}, + ) + assert created.status_code == 201 + return created.json() + + +def _columns(page: dict) -> list[dict]: + return page["blocks"][0]["blocks"] + + +def _cards_in(column: dict) -> list[dict]: + return column["blocks"][0]["cards"] + + +def _tables(page: dict) -> list[dict]: + return page["blocks"][0]["blocks"] + + +async def test_empty_board_shows_columns_and_create_actions(druks_client): + page = (await druks_client.get(f"{_PAGES}/board")).json() + + assert page["title"] == "Board" + assert [control["label"] for control in page["controls"]] == ["New ticket", "New project"] + assert [control["operation"] for control in page["controls"]] == [ + "create_ticket", + "create_ticket_project", + ] + columns = _columns(page) + assert [column["title"] for column in columns] == BOARD_COLUMNS + for column in columns: + cards = column["blocks"][0] + assert cards["cards"] == [] + assert cards["empty"]["title"] == "Nothing here" + + +async def test_created_ticket_lands_in_todo_on_board_and_list(druks_client): + project = await _open_project(druks_client) + ticket = await _open_ticket(druks_client, project["id"], title="Ship the board") + + board = (await druks_client.get(f"{_PAGES}/board")).json() + by_title = {column["title"]: column for column in _columns(board)} + (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"]} + for title in BOARD_COLUMNS: + if title != "Todo": + assert _cards_in(by_title[title]) == [] + + listed = (await druks_client.get(f"{_PAGES}/list")).json() + by_section = {table["title"]: table for table in _tables(listed)} + assert [table["title"] for table in _tables(listed)] == LIST_SECTIONS + (row,) = by_section["Todo"]["rows"] + assert row["cells"][0]["text"] == "DRU-1" + assert row["cells"][1]["text"] == "Ship the board" + for title in LIST_SECTIONS: + if title != "Todo": + assert by_section[title]["rows"] == [] + + +async def test_moving_a_ticket_updates_board_and_list(druks_client): + project = await _open_project(druks_client) + ticket = await _open_ticket(druks_client, project["id"], title="In flight") + moved = await druks_client.post( + f"{_TICKETS}/{ticket['identifier']}/status", + json={"status": "in_progress"}, + ) + assert moved.status_code == 200 + + board = (await druks_client.get(f"{_PAGES}/board")).json() + by_title = {column["title"]: column for column in _columns(board)} + assert [card["title"] for card in _cards_in(by_title["In Progress"])] == ["In flight"] + assert _cards_in(by_title["Todo"]) == [] + + listed = (await druks_client.get(f"{_PAGES}/list")).json() + by_section = {table["title"]: table for table in _tables(listed)} + assert [row["cells"][1]["text"] for row in by_section["In Progress"]["rows"]] == ["In flight"] + assert by_section["Todo"]["rows"] == [] + + +async def test_cancelled_tickets_are_off_the_board_and_last_on_the_list(druks_client): + project = await _open_project(druks_client) + await _open_ticket(druks_client, project["id"], title="live") + gone = await _open_ticket(druks_client, project["id"], title="gone") + await druks_client.post( + f"{_TICKETS}/{gone['identifier']}/status", + json={"status": "cancelled"}, + ) + + board = (await druks_client.get(f"{_PAGES}/board")).json() + cards = [card["title"] for column in _columns(board) for card in _cards_in(column)] + assert cards == ["live"] + + listed = (await druks_client.get(f"{_PAGES}/list")).json() + tables = _tables(listed) + assert [table["title"] for table in tables] == LIST_SECTIONS + assert tables[-1]["title"] == "Cancelled" + assert [row["cells"][1]["text"] for row in tables[-1]["rows"]] == ["gone"] + + +async def test_ticket_page_follows_the_row_and_comments_refresh_the_region(druks_client): + project = await _open_project(druks_client) + created = await _open_ticket(druks_client, project["id"], title="Follow me") + row = await Ticket.get_for_identifier(created["identifier"]) + + page = (await druks_client.get(f"{_PAGES}/tickets/{created['identifier']}")).json() + + assert page["title"] == "Follow me" + assert page["follows"] == {"subjectType": "ticket", "subjectId": str(row.id)} + assert page["controls"][0]["operation"] == "set_status" + comments = next(block for block in page["blocks"] if block.get("name") == "comments") + assert comments["title"] == "Comments" + assert comments["blocks"][0]["title"] == "No comments yet" + comment_form = comments["blocks"][1] + assert comment_form["action"]["operation"] == "add_comment" + assert comment_form["action"]["refresh"] == "region" + + written = await druks_client.post( + f"{_TICKETS}/{created['identifier']}/comments", + json={"body": "looks good"}, + ) + assert written.status_code == 201 + + after = (await druks_client.get(f"{_PAGES}/tickets/{created['identifier']}")).json() + thread = next(block for block in after["blocks"] if block.get("name") == "comments") + assert thread["blocks"][0]["blocks"][0]["text"] == "looks good" + + +async def test_unknown_ticket_page_is_an_empty_state(druks_client): + page = (await druks_client.get(f"{_PAGES}/tickets/NOPE-1")).json() + + assert page["blocks"][0]["title"] == "No such ticket" + assert page["blocks"][0]["controls"][0]["page"] == "board" + + +async def test_roster_names_the_board_and_list_pages(druks_client): + roster = {entry["name"]: entry for entry in (await druks_client.get("/api/apps")).json()} + + names = [page["name"] for page in roster["software_factory"]["pages"]] + # Route-match order: the static list wins over the parameterized ticket. + assert names == ["board", "list", "ticket"] + assert roster["software_factory"]["navigation"] == [] diff --git a/backend/tests/test_ui_pages.py b/backend/tests/test_ui_pages.py index c078c22f..cf9bd14b 100644 --- a/backend/tests/test_ui_pages.py +++ b/backend/tests/test_ui_pages.py @@ -139,11 +139,11 @@ def test_a_child_of_a_child_fails_at_declaration(): history.child("/deeper") -def test_a_missing_landing_page_fails(): +def test_a_missing_landing_page_is_allowed(): declare("no_landing", "/notes", "notes") - with pytest.raises(PageRouteError, match=r"declares 0 pages at '/'"): - list_pages_for_app("no_landing", "no_landing") + routes = list_pages_for_app("no_landing", "no_landing") + assert [page.name for page in routes] == ["notes"] def test_two_landing_pages_fail(): diff --git a/frontend/src/apps/software_factory/ui.tsx b/frontend/src/apps/software_factory/ui.tsx index c6e3e478..fb325933 100644 --- a/frontend/src/apps/software_factory/ui.tsx +++ b/frontend/src/apps/software_factory/ui.tsx @@ -1,3 +1,4 @@ +import { AppPage } from '../../druksui/AppPage' import { registerAppUI, targetQuery } from '../registry' import { SOFTWARE_FACTORY } from './api' import { parseLeadingId } from './slug' @@ -13,6 +14,8 @@ registerAppUI({ home: `/${SOFTWARE_FACTORY}`, navigation: [ [`/${SOFTWARE_FACTORY}`, 'Overview'], + [`/${SOFTWARE_FACTORY}/board`, 'board'], + [`/${SOFTWARE_FACTORY}/list`, 'list'], [`/${SOFTWARE_FACTORY}/history`, 'history'], [`/${SOFTWARE_FACTORY}/projects`, 'projects'], ], @@ -28,6 +31,12 @@ registerAppUI({ : undefined, routes: [ { path: `/${SOFTWARE_FACTORY}`, render: () => }, + { path: `/${SOFTWARE_FACTORY}/board`, render: () => }, + { path: `/${SOFTWARE_FACTORY}/list`, render: () => }, + { + path: `/${SOFTWARE_FACTORY}/tickets/:identifier`, + render: () => , + }, { path: `/${SOFTWARE_FACTORY}/history`, render: () => }, { path: `/${SOFTWARE_FACTORY}/projects`, render: () => }, {