diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index f795978457c..81da9782871 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -722,6 +722,70 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("collapses long blocked-by and sub-task lists until the more button is clicked", async () => { + const blockedBy = Array.from({ length: 7 }, (_, index) => ({ + id: `blocker-${index + 1}`, + identifier: `BLOCK-${index + 1}`, + title: `Blocker ${index + 1}`, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + })) as NonNullable; + const childIssues = Array.from({ length: 7 }, (_, index) => createIssue({ + id: `child-${index + 1}`, + identifier: `SUB-${index + 1}`, + title: `Sub-task ${index + 1}`, + })); + const root = renderProperties(container, { + issue: createIssue({ blockedBy }), + childIssues, + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + + expect(container.textContent).toContain("BLOCK-5"); + expect(container.textContent).not.toContain("BLOCK-6"); + expect(container.textContent).toContain("SUB-5"); + expect(container.textContent).not.toContain("SUB-6"); + expect( + Array.from(container.querySelectorAll("button")).filter((button) => + button.textContent?.trim() === "and 2 more...", + ), + ).toHaveLength(2); + + const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.trim() === "and 2 more...", + ); + expect(expandBlockedBy).not.toBeUndefined(); + await act(async () => { + expandBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("BLOCK-6"); + expect(container.textContent).toContain("BLOCK-7"); + expect(container.textContent).not.toContain("SUB-6"); + + const expandSubTasks = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.trim() === "and 2 more...", + ); + expect(expandSubTasks).not.toBeUndefined(); + await act(async () => { + expandSubTasks!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("SUB-6"); + expect(container.textContent).toContain("SUB-7"); + expect( + Array.from(container.querySelectorAll("button")).filter((button) => + button.textContent?.trim() === "and 2 more...", + ), + ).toHaveLength(0); + + act(() => root.unmount()); + }); + it("shows a green service link above the workspace row for a live non-main workspace", async () => { mockProjectsApi.list.mockResolvedValue([createProject()]); const serviceUrl = "http://127.0.0.1:62475"; diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index a3d419255b6..b610c5289f0 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -154,6 +154,7 @@ interface IssuePropertiesProps { } const ISSUE_BLOCKER_SEARCH_LIMIT = 50; +const ISSUE_PROPERTY_RELATION_PREVIEW_COUNT = 5; function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -321,6 +322,25 @@ function RemovableIssueReferencePill({ ); } +function ExpandRelationListButton({ + hiddenCount, + onClick, +}: { + hiddenCount: number; + onClick: () => void; +}) { + if (hiddenCount <= 0) return null; + return ( + + ); +} + /** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */ function PropertyPicker({ inline, @@ -408,6 +428,8 @@ export function IssueProperties({ const [projectSearch, setProjectSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = useState(""); + const [blockedByExpanded, setBlockedByExpanded] = useState(false); + const [subTasksExpanded, setSubTasksExpanded] = useState(false); const [parentOpen, setParentOpen] = useState(false); const [parentSearch, setParentSearch] = useState(""); const [reviewersOpen, setReviewersOpen] = useState(false); @@ -1673,6 +1695,15 @@ export function IssueProperties({ ); const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? []; + const blockedByRelations = issue.blockedBy ?? []; + const visibleBlockedByRelations = blockedByExpanded + ? blockedByRelations + : blockedByRelations.slice(0, ISSUE_PROPERTY_RELATION_PREVIEW_COUNT); + const hiddenBlockedByCount = blockedByRelations.length - visibleBlockedByRelations.length; + const visibleChildIssues = subTasksExpanded + ? childIssues + : childIssues.slice(0, ISSUE_PROPERTY_RELATION_PREVIEW_COUNT); + const hiddenChildIssueCount = childIssues.length - visibleChildIssues.length; const descendantIssueIds = useMemo(() => { if (!allIssues?.length) return new Set(); const childrenByParentId = new Map(); @@ -1985,9 +2016,13 @@ export function IssueProperties({ {inline ? (
- {(issue.blockedBy ?? []).map((relation) => ( + {visibleBlockedByRelations.map((relation) => ( ))} + setBlockedByExpanded(true)} + /> {renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))} {blockedByOpen && ( @@ -1998,9 +2033,13 @@ export function IssueProperties({
) : ( - {(issue.blockedBy ?? []).map((relation) => ( + {visibleBlockedByRelations.map((relation) => ( ))} + setBlockedByExpanded(true)} + /> { @@ -2031,10 +2070,14 @@ export function IssueProperties({
{childIssues.length > 0 - ? childIssues.map((child) => ( + ? visibleChildIssues.map((child) => ( )) : null} + setSubTasksExpanded(true)} + /> {onAddSubIssue ? (