feat(issues): add table sub-issue action (#5738)

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Jiayuan Zhang
2026-07-22 13:33:18 +08:00
committed by GitHub
parent d804dedcc6
commit fe12278863
5 changed files with 92 additions and 0 deletions

View File

@@ -55,9 +55,11 @@ function makeRow(title: string): Extract<
const baseProps = {
onUpdate: vi.fn(),
onOpen: vi.fn(),
onCreateSubIssue: vi.fn(),
onToggleParent: vi.fn(),
toggleLabel: "Toggle sub-issues",
renameLabel: "Rename issue",
createSubIssueLabel: "Create sub-issue",
};
/** Editing state lives in the table (one editor at a time); mirror that. */
@@ -66,11 +68,13 @@ function Harness({
onOpen,
onUpdate,
onEditingChange,
onCreateSubIssue,
}: {
title: string;
onOpen?: () => void;
onUpdate?: (updates: unknown) => void;
onEditingChange?: (editing: boolean) => void;
onCreateSubIssue?: () => void;
}) {
const [editing, setEditing] = useState(false);
return (
@@ -84,6 +88,7 @@ function Harness({
}}
onOpen={onOpen ?? baseProps.onOpen}
onUpdate={onUpdate ?? baseProps.onUpdate}
onCreateSubIssue={onCreateSubIssue ?? baseProps.onCreateSubIssue}
/>
);
}
@@ -120,6 +125,23 @@ describe("InlineTitle", () => {
expect(screen.queryByRole("textbox")).toBeNull();
});
it("opens sub-issue creation without also navigating into the issue", () => {
const onCreateSubIssue = vi.fn();
const rowClick = vi.fn();
render(
<div onClick={rowClick}>
<Harness title="Original" onCreateSubIssue={onCreateSubIssue} />
</div>,
);
fireEvent.click(
screen.getByRole("button", { name: "Create sub-issue" }),
);
expect(onCreateSubIssue).toHaveBeenCalledTimes(1);
expect(rowClick).not.toHaveBeenCalled();
});
it("commits on click-away without also navigating into the issue", async () => {
const user = userEvent.setup({ delay: null });
const onUpdate = vi.fn();

View File

@@ -20,6 +20,7 @@ import type { Issue } from "@multica/core/types";
import { renderWithI18n } from "../../test/i18n";
import { IssueSurfaceSelectionProvider } from "../surface/selection-context";
import type { IssueSurfaceSelection } from "../surface/selection-context";
import type { IssueCreateDefaults } from "../surface/types";
import type { ChildProgress } from "./list-row";
import { TableView, useReleaseEditingCellOnUnmount } from "./table-view";
@@ -131,10 +132,12 @@ function Harness({
issues,
childProgressMap,
surfaceKey,
onCreateIssue = () => {},
}: {
issues: Issue[];
childProgressMap: Map<string, ChildProgress>;
surfaceKey: string;
onCreateIssue?: (defaults: IssueCreateDefaults) => void;
}) {
return (
<ViewStoreProvider store={getIssueSurfaceViewStore(surfaceKey)}>
@@ -149,6 +152,7 @@ function Harness({
total={issues.length}
search=""
onSearchChange={() => {}}
onCreateIssue={onCreateIssue}
exportIssues={() => Promise.resolve(issues)}
resolveExportLookups={() =>
Promise.resolve({
@@ -241,6 +245,37 @@ describe("TableView cell editors under data refresh", () => {
expect(screen.queryByRole("button", { name: /Backlog/ })).toBeNull();
expect(identifiers()).toEqual(["MUL-b", "MUL-a"]);
}, 20_000);
it("opens creation with the row as parent and inherits its project", async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const onCreateIssue = vi.fn();
const issue = {
...makeIssue("a", "Alpha task", "todo"),
project_id: "project-1",
};
renderWithI18n(
<QueryClientProvider client={queryClient}>
<Harness
issues={[issue]}
childProgressMap={new Map()}
surfaceKey={`test-create-sub-issue-${Math.floor(Math.random() * 1e9)}`}
onCreateIssue={onCreateIssue}
/>
</QueryClientProvider>,
);
const row = screen.getByText("MUL-a").closest("tr")!;
await user.click(
within(row).getByRole("button", { name: "Create sub-issue" }),
);
expect(onCreateIssue).toHaveBeenCalledWith({
parent_issue_id: "a",
parent_issue_identifier: "MUL-a",
project_id: "project-1",
});
});
});
// Row virtualization unmounts a cell when its row scrolls out of the window

View File

@@ -99,6 +99,7 @@ import { ProjectPicker } from "../../projects/components/project-picker";
import { useT } from "../../i18n";
import { useIssueSurfaceActionsOptional } from "../surface/actions-context";
import { useIssueSurfaceSelection } from "../surface/selection-context";
import type { IssueCreateDefaults } from "../surface/types";
import { ProgressRing } from "./progress-ring";
import {
AssigneePicker,
@@ -137,6 +138,7 @@ type TableViewProps = {
total: number;
search: string;
onSearchChange: (query: string) => void;
onCreateIssue: (defaults: IssueCreateDefaults) => void;
exportIssues: () => Promise<Issue[]>;
resolveExportLookups: (needs: {
projects: boolean;
@@ -476,9 +478,11 @@ export function InlineTitle({
onEditingChange,
onUpdate,
onOpen,
onCreateSubIssue,
onToggleParent,
toggleLabel,
renameLabel,
createSubIssueLabel,
}: {
row: Extract<IssueTableDisplayRow, { kind: "issue" }>;
/** Rename state is owned by the table (one editor at a time) so it also
@@ -488,9 +492,11 @@ export function InlineTitle({
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
/** Navigate to the issue — clicking the title is the primary way IN. */
onOpen: () => void;
onCreateSubIssue: () => void;
onToggleParent: () => void;
toggleLabel: string;
renameLabel: string;
createSubIssueLabel: string;
}) {
const [draft, setDraft] = useState(row.issue.title);
const editingRef = useRef(editing);
@@ -585,6 +591,17 @@ export function InlineTitle({
>
{row.issue.title}
</button>
<button
type="button"
aria-label={createSubIssueLabel}
className="shrink-0 rounded p-1 text-muted-foreground/60 opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
onCreateSubIssue();
}}
>
<Plus className="size-3" />
</button>
<button
type="button"
aria-label={renameLabel}
@@ -732,6 +749,7 @@ type TableViewMeta = {
setEditingCellKey: (key: string | null) => void;
updateIssue: (issueId: string, updates: Partial<UpdateIssueRequest>) => void;
openIssue: (issueId: string) => void;
createSubIssue: (issue: Issue) => void;
toggleTableParentCollapsed: (issueId: string) => void;
handleIssueSelection: (issueId: string, shiftKey: boolean) => void;
getActorName: (actorType: string, actorId: string) => string;
@@ -923,9 +941,11 @@ function IssueTableBodyCell({
onEditingChange={setEditorOpen}
onUpdate={onUpdate}
onOpen={() => meta.openIssue(issue.id)}
onCreateSubIssue={() => meta.createSubIssue(issue)}
onToggleParent={() => meta.toggleTableParentCollapsed(issue.id)}
toggleLabel={t(($) => $.table.toggle_sub_issues)}
renameLabel={t(($) => $.table.rename_title)}
createSubIssueLabel={t(($) => $.actions.create_sub_issue)}
/>
);
case "identifier":
@@ -1065,6 +1085,7 @@ export function TableView({
total,
search,
onSearchChange,
onCreateIssue,
exportIssues,
resolveExportLookups,
}: TableViewProps) {
@@ -1277,6 +1298,16 @@ export function TableView({
[navigation, paths],
);
const createSubIssue = useCallback(
(issue: Issue) =>
onCreateIssue({
parent_issue_id: issue.id,
parent_issue_identifier: issue.identifier,
...(issue.project_id ? { project_id: issue.project_id } : {}),
}),
[onCreateIssue],
);
const onSort = useCallback(
(field: SortField, direction: "asc" | "desc") => {
setSortBy(field);
@@ -1297,6 +1328,7 @@ export function TableView({
setEditingCellKey,
updateIssue,
openIssue,
createSubIssue,
toggleTableParentCollapsed,
handleIssueSelection,
getActorName,

View File

@@ -290,6 +290,7 @@ function IssueSurfaceContent({
total={controller.flatTotal}
search={controller.tableSearch}
onSearchChange={controller.setTableSearch}
onCreateIssue={openCreateIssue}
exportIssues={controller.exportTableIssues}
resolveExportLookups={controller.resolveTableExportLookups}
/>

View File

@@ -11,6 +11,8 @@ export type IssueCreateDefaults = Partial<
assignee_type?: CreateIssueRequest["assignee_type"] | null;
assignee_id?: string | null;
parent_issue_id?: string | null;
/** Display-only context for the create dialog while the parent query loads. */
parent_issue_identifier?: string;
project_id?: string | null;
};