mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -290,6 +290,7 @@ function IssueSurfaceContent({
|
||||
total={controller.flatTotal}
|
||||
search={controller.tableSearch}
|
||||
onSearchChange={controller.setTableSearch}
|
||||
onCreateIssue={openCreateIssue}
|
||||
exportIssues={controller.exportTableIssues}
|
||||
resolveExportLookups={controller.resolveTableExportLookups}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user