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: () => },
{