lib/creation-relations-dialogs.test.ts
Rama main · réplica de lectura del repositorio original.
// @vitest-environment jsdom
import * as React from "react";
import { act } from "react";
import { createPortal } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import en from "@/messages/en.json";
import type { IssueDraft, ObjectiveDraft } from "./drafts";
import { HELD_DRAFT_DELAY_MS } from "./use-held-draft";
import type { Issue, Objective, PendingRelationInput, Project } from "./types";
const fixture = vi.hoisted(() => ({
issues: [] as Issue[], objectives: [] as Objective[],
drafts: [] as (IssueDraft | ObjectiveDraft)[], save: vi.fn(), remove: vi.fn(),
mobile: false, smartFill: true, smartFillCreated: true, smartFillObjectives: true, smartFillRelations: true, smartFillTitle: true, smartFillObjectiveName: true, queryProjects: [] as (string | null)[],
editorDocuments: [] as unknown[], editorAutoFocus: [] as boolean[],
}));
vi.mock("@/lib/use-mobile-layout", () => ({ useMobileLayout: () => fixture.mobile }));
vi.mock("next-intl", () => ({ useTranslations: (namespace: string) =>
(key: string, values?: Record<string, string>) => ((namespace.split(".").reduce<unknown>(
(messages, part) => (messages as Record<string, unknown>)[part], en) as Record<string, string>)[key] ?? key)
.replace(/\{(\w+)\}/g, (match, name: string) => values?.[name] ?? match),
useLocale: () => "en",
}));
vi.mock("@hugeicons/react", () => ({ HugeiconsIcon: () => null }));
vi.mock("@/components/ui/app-tooltip", () => ({ AppTooltip: ({ children }: { children: React.ReactNode }) => children }));
// Closing runs the close-autofocus handler, as the real popover does once its
// exit ends: the relation type hands over to the palette there.
vi.mock("@/components/search-menu", () => ({ SearchMenu: function SearchMenu({ open, onOpenChange, trigger, children, searchValue, onSearchValueChange, container, modal, onCloseAutoFocus }: {
open: boolean; onOpenChange: (value: boolean) => void; trigger: React.ReactElement; children: React.ReactNode;
searchValue: string; onSearchValueChange: (value: string) => void;
container?: HTMLElement | null; modal?: boolean; onCloseAutoFocus?: (event: Event) => void;
}) {
const wasOpen = React.useRef(open);
React.useEffect(() => {
if (wasOpen.current && !open) onCloseAutoFocus?.(new Event("focus", { cancelable: true }));
wasOpen.current = open;
});
return React.createElement("div", { "data-picker": true },
React.cloneElement(trigger, { onClick: () => onOpenChange(!open) } as React.HTMLAttributes<HTMLElement>),
open && createPortal(React.createElement("div", { "data-modal": modal }, React.createElement("input", { "aria-label": "Search relations", value: searchValue, onChange: (e: React.ChangeEvent<HTMLInputElement>) => onSearchValueChange(e.target.value) }), children), container ?? document.body));
} }));
// Stubs for the UI primitives the component under test renders.
const uiStubs = vi.hoisted(() => {
let value: Promise<Record<string, unknown>> | undefined;
return () => (value ??= (async () => {
const wrap = ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children);
const item = ({ children, onSelect, disabled }: { children: React.ReactNode; onSelect: () => void; disabled?: boolean }) =>
React.createElement("button", { type: "button", onClick: onSelect, disabled }, children);
return {
cn: (...values: unknown[]) => values.filter(Boolean).join(" "),
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
Dialog: ({ open, onOpenChange, children }: { open: boolean; onOpenChange: (next: boolean) => void; children: React.ReactNode }) =>
open ? React.createElement("div", null, React.createElement("button", { type: "button", onClick: () => onOpenChange(false) }, "Close creation"), children) : null,
// These stub contents deliberately leave keyboard ownership to the fallback.
DialogContent: ({ children }: { children: React.ReactNode }) => React.createElement("div", { "data-dialog-content": true }, children),
Sheet: ({ open, onOpenChange, children }: { open: boolean; onOpenChange: (next: boolean) => void; children: React.ReactNode }) =>
open ? React.createElement("div", null, React.createElement("button", { type: "button", onClick: () => onOpenChange(false) }, "Close creation"), children) : null,
SheetContent: wrap, SheetTitle: wrap, SheetTrigger: wrap, SheetClose: wrap,
DialogTitle: wrap, DropdownMenuLabel: wrap, CommandGroup: wrap,
CommandItem: item, DropdownMenuItem: item,
Spinner: () => null,
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => React.createElement("button", props, children),
SplitButton: ({ children, menu, type, disabled }: { children: React.ReactNode; menu: React.ReactNode; type: "submit"; disabled: boolean }) =>
React.createElement("div", null, React.createElement("button", { type, disabled }, children), menu),
Switch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: (value: boolean) => void }) =>
React.createElement("button", { type: "button", onClick: () => onCheckedChange(!checked) }, "Create more toggle"),
Popover: wrap, PopoverContent: () => null, PopoverTrigger: wrap,
};
})());
});
vi.mock("sonner", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), toast: stubs.toast };
});
vi.mock("@/components/ui/dialog", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), Dialog: stubs.Dialog, DialogContent: stubs.DialogContent, DialogTitle: stubs.DialogTitle };
});
vi.mock("@/components/ui/sheet", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), Sheet: stubs.Sheet, SheetContent: stubs.SheetContent, SheetTitle: stubs.SheetTitle, SheetTrigger: stubs.SheetTrigger, SheetClose: stubs.SheetClose };
});
vi.mock("@/components/ui/dropdown-menu", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), DropdownMenuLabel: stubs.DropdownMenuLabel, DropdownMenuItem: stubs.DropdownMenuItem };
});
vi.mock("@/components/ui/command", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), CommandGroup: stubs.CommandGroup, CommandItem: stubs.CommandItem };
});
vi.mock("@/components/ui/spinner", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), Spinner: stubs.Spinner };
});
vi.mock("@/components/ui/button", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), Button: stubs.Button };
});
vi.mock("@/components/ui/split-button", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), SplitButton: stubs.SplitButton };
});
vi.mock("@/components/ui/switch", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), Switch: stubs.Switch };
});
vi.mock("@/components/ui/popover", async (importOriginal) => {
const stubs = await uiStubs();
return { ...(await importOriginal<Record<string, unknown>>()), Popover: stubs.Popover, PopoverContent: stubs.PopoverContent, PopoverTrigger: stubs.PopoverTrigger };
});
vi.mock("@/lib/use-issues-query", () => ({ useIssuesQuery: (project: string | null) => {
fixture.queryProjects.push(project);
return { issues: project ? fixture.issues : [], loading: false };
} }));
vi.mock("@/lib/use-members-query", () => ({ useMembersQuery: () => ({ members: [] }) }));
vi.mock("@/lib/use-categories-query", () => ({ useCategoriesQuery: () => ({ categories: [] }) }));
// The real select is a searchable menu (SearchSelect, stubbed out here): one
// button per project is enough to drive "Create in".
vi.mock("@/components/creation-project-select", () => ({ CreationProjectSelect: ({ projects, value, onChange }: {
projects: Project[]; value: string; onChange: (projectId: string) => void;
}) => React.createElement("div", { "data-create-in": value }, projects.map((p) =>
React.createElement("button", { key: p.id, type: "button", onClick: () => onChange(p.id) }, `Create in ${p.name}`))),
}));
// Picker order has its own suite (lib/objective-order.test.ts); these tests
// render without a QueryClient, so the hook keeps the fixture order.
vi.mock("@/lib/use-ordered-objectives", () => ({ useOrderedObjectives: <T,>(objectives: T[]) => objectives }));
vi.mock("@/lib/use-objectives-query", () => ({ useObjectivesQuery: (project: string | null) => ({ objectives: project ? fixture.objectives : [], loading: false }) }));
vi.mock("@/lib/auth-context", () => ({ useAuth: () => ({ user: { id: "user", user_metadata: { smart_fill: fixture.smartFill, smart_fill_created: fixture.smartFillCreated, smart_fill_objectives: fixture.smartFillObjectives, smart_fill_relations: fixture.smartFillRelations, smart_fill_title: fixture.smartFillTitle, smart_fill_objective_name: fixture.smartFillObjectiveName } } }) }));
vi.mock("@/lib/use-analytics", () => ({ useAnalytics: () => ({ track: vi.fn() }) }));
vi.mock("@/lib/use-track-view", () => ({ useTrackView: vi.fn() }));
vi.mock("@/lib/use-mention-sources", () => ({ useDescriptionMentions: () => [] }));
vi.mock("@/lib/use-resource-mentions", () => ({ useResourceMentions: (base: unknown) => base }));
vi.mock("@/lib/use-arrow-field", () => ({ useArrowField: (ref: React.RefObject<HTMLTextAreaElement>) => ({ ref, read: (e: React.ChangeEvent<HTMLTextAreaElement>) => e.target.value }) }));
vi.mock("@/lib/use-attachment-uploads", () => ({ landedResources: () => [], useAttachmentUploads: () => ({
inputs: [], pending: [], uploading: false, clear: vi.fn(), restore: vi.fn(), addFiles: vi.fn(),
}) }));
vi.mock("@/lib/use-drafts", () => ({ useDrafts: () => ({
drafts: fixture.drafts, find: (id: string) => fixture.drafts.find((draft) => draft.id === id), save: fixture.save, remove: fixture.remove,
}) }));
vi.mock("@/lib/use-issue-dictation", () => ({ useIssueDictation: () => ({ busy: false, clearHistory: vi.fn(), reset: vi.fn() }) }));
vi.mock("@/lib/use-objective-dictation", () => ({ useObjectiveDictation: () => ({ busy: false, reset: vi.fn() }) }));
vi.mock("@/components/auto-textarea", () => ({ AutoTextarea: "textarea" }));
// Description documents (MIN-728) have their own tests; these surfaces open
// their Markdown.
vi.mock("@/lib/use-rich-document", () => ({
useRichDocument: () => ({ document: null, remember: () => {} }),
}));
vi.mock("@/components/editor/document-editor-lazy", () => ({ LazyDocumentEditor: ({ className, document, autoFocus }: { className: string; document?: unknown; autoFocus?: boolean }) => {
fixture.editorDocuments.push(document ?? null);
fixture.editorAutoFocus.push(autoFocus === true);
return React.createElement("div", { className }, React.createElement("div", { contentEditable: true, suppressContentEditableWarning: true, tabIndex: 0 }, ""));
}, useIdleDocumentEditorPreload: vi.fn() }));
vi.mock("@/components/resources", () => ({ AddResourceButton: () => null, ResourcePills: () => null, ComposerResourcePills: () => null, DropOverlay: () => null, pasteFileHandler: () => undefined, useFileDrop: () => ({ handlers: {} }) }));
vi.mock("@/components/issue-compact-fields", () => ({
// The assignee (an objective's lead) and the due date mark the property row
// around the relations.
AssigneeCompact: () => React.createElement("span", { "data-field": "assignee" }), CategoriesCompact: () => null,
DueDateCompact: () => React.createElement("span", { "data-field": "date" }),
ObjectiveCompact: () => null, StatusCompact: () => null,
// Priority and effort keep a trigger and one option, enough to see Smart Fill
// hide them and a shortcut bring one back (MIN-746).
PriorityCompact: ({ open, onChange, onOpenChange }: { open: boolean; onChange: (value: string) => void; onOpenChange: (open: boolean) => void }) =>
React.createElement("div", null, React.createElement("button", { type: "button", "aria-label": "Change priority" }),
open && React.createElement("button", { type: "button", onClick: () => { onChange("high"); onOpenChange(false); } }, "High")),
EffortCompact: ({ open, onChange, onOpenChange }: { open: boolean; onChange: (value: string) => void; onOpenChange: (open: boolean) => void }) =>
React.createElement("div", null, React.createElement("button", { type: "button", "aria-label": "Change effort" }),
open && React.createElement("button", { type: "button", onClick: () => { onChange("m"); onOpenChange(false); } }, "M")),
// The "Set manually" → Title entry, when offered, is one button (MIN-813).
SmartFillCompact: ({ value, onChange, manual }: { value: boolean; onChange: (value: boolean) => void; manual?: { onTitleRequest?: () => void; onTitleFocus?: () => void } }) =>
React.createElement(React.Fragment, null,
React.createElement("button", { type: "button", onClick: () => onChange(!value) }, "Smart Fill"),
manual?.onTitleRequest && React.createElement("button", { type: "button", onClick: () => { manual.onTitleRequest!(); queueMicrotask(() => manual.onTitleFocus?.()); } }, "Set title manually")),
}));
vi.mock("@/components/issue-indicators", () => ({ RelationIcon: () => null, StatusIndicator: () => null, ObjectiveStatusIndicator: () => null }));
vi.mock("@/components/date-time-picker", () => ({ DateTimePicker: () => React.createElement("span", { "data-field": "date" }) }));
vi.mock("@/components/search-select", () => ({ SearchSelect: () => null }));
// The semantic group needs a real cmdk Command; it has its own test (lib/semantic-issue-group.test.tsx).
vi.mock("@/components/semantic-issue-group", () => ({ SemanticIssueGroup: () => null }));
vi.mock("@/components/project-orb", () => ({ ProjectOrb: () => null }));
vi.mock("@/components/numo-icon", () => ({ NumoIcon: () => null }));
vi.mock("@/components/agent-beam", () => ({ AgentBeamOverlay: () => null }));
vi.mock("@/components/ai-elements/dictate-button", () => ({ DictateButton: ({ showLabel = false }: { showLabel?: boolean }) => React.createElement("button", { type: "button", "data-prominent-dictation": String(showLabel) }, "Dictate") }));
vi.mock("@/components/send-shortcut", () => ({ SendShortcutTooltip: ({ children }: { children: React.ReactNode }) => children }));
import { CreateIssueDialog } from "@/components/create-issue-dialog";
import { ObjectiveDialog } from "@/components/objective-dialog";
import { CreationRelationsCompact } from "@/components/creation-relations";
import { PICKER_SEARCH_LABEL, PalettePickerTestHost } from "@/test/palette-picker-host";
// A relation's targets open in the ⌘K palette (MIN-844), stood in for here.
const withPalette = (element: React.ReactElement) =>
React.createElement(React.Fragment, null, element, React.createElement(PalettePickerTestHost));
let root: Root;
let host: HTMLDivElement;
const project = { id: "project", name: "Current project", key: "MIN", issue_seq: 499 } as Project;
const otherProject = { id: "other", name: "Other project", key: "OTH", issue_seq: 9 } as Project;
const issue = { id: "target-issue", project_id: "project", number: 12, title: "Target issue", status: "todo" } as Issue;
const objective = { id: "target-objective", project_id: "project", name: "Target objective", status: "planned" } as Objective;
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
fixture.issues = [issue, { ...issue, id: "closed", title: "Closed issue", status: "done" }, { ...issue, id: "foreign", project_id: "other", title: "Foreign issue" }];
fixture.objectives = [objective, { ...objective, id: "closed-goal", name: "Closed objective", status: "done" }, { ...objective, id: "foreign-goal", name: "Foreign objective", project_id: "other" }];
fixture.mobile = false;
fixture.drafts = []; fixture.editorDocuments = []; fixture.editorAutoFocus = []; fixture.smartFill = true; fixture.smartFillCreated = true; fixture.smartFillObjectives = true; fixture.smartFillRelations = true; fixture.smartFillTitle = false; fixture.smartFillObjectiveName = false; fixture.queryProjects = [];
fixture.save.mockImplementation(async (draft) => { fixture.drafts = [draft]; });
fixture.remove.mockImplementation(async (id) => { fixture.drafts = fixture.drafts.filter((draft) => draft.id !== id); return true; });
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); });
async function click(label: string) {
const button = [...document.querySelectorAll("button")].find((element) => element.textContent?.trim() === label || element.getAttribute("aria-label") === label);
expect(button, `Button ${label}`).toBeDefined();
await act(() => button!.click());
}
async function typeTitle(value: string) {
const textarea = host.querySelector("textarea")!;
await act(() => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function addRelation(type: PendingRelationInput["type"], target: "issue" | "objective") {
await click(en.Relations.addRelationAria);
await click(en.Relations[type]);
// A closed issue stays linkable (listed last); a closed objective does not.
expect(document.body.textContent).toContain("Closed issue");
expect(document.body.textContent).not.toContain("Foreign issue");
expect(document.body.textContent).toContain("Closed objective");
expect(document.body.textContent).not.toContain("Foreign objective");
await click(target === "issue" ? "MIN-12Target issue" : "Target objective");
}
async function mountDialog(kind: "issue" | "objective", withProjectMenu = true) {
const create = vi.fn().mockResolvedValue({ id: "created" });
const createOther = vi.fn().mockResolvedValue({ id: "created" });
let open = true;
let projects = [project, otherProject];
// The draft the drafts tray asks the dialog to restore (MIN-753).
let initialDraftId: string | null = null;
const render = () => root.render(withPalette(kind === "issue"
? React.createElement(CreateIssueDialog, { open, onOpenChange, projectId: project.id, projects, members: [], categories: [], objectives: [], onCreate: create, onCreateInProject: createOther, initialDraftId })
: React.createElement(ObjectiveDialog, { open, onOpenChange, projectId: project.id, projectKey: project.key, projects: withProjectMenu ? [project, otherProject] : [], members: [], onCreate: create, onCreateInProject: createOther, onUpdate: vi.fn(), initialDraftId })));
const onOpenChange = (next: boolean) => { open = next; render(); };
await act(render);
return {
create, createOther,
close: () => onOpenChange(false),
reopen: (draftId: string | null = null) => act(() => { initialDraftId = draftId; onOpenChange(true); }),
setProjects: (next: Project[]) => act(() => { projects = next; render(); }),
};
}
describe("creation relation controls", () => {
it.each([
{ defaultFill: true, crossProject: false },
{ defaultFill: false, crossProject: false },
{ defaultFill: true, crossProject: true },
{ defaultFill: false, crossProject: true },
])("restores Smart Fill after Create more with $defaultFill as the default and cross-project=$crossProject", async ({ defaultFill, crossProject }) => {
fixture.smartFillCreated = defaultFill;
const dialog = await mountDialog("issue");
await click("Create more toggle");
await click("Smart Fill");
await typeTitle("Issue with a one-ticket override");
if (crossProject) await click("Create in Other project");
await act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
const target = crossProject ? dialog.createOther : dialog.create;
expect(target.mock.calls[0].at(-1)).toMatchObject({ smart_fill: !defaultFill });
await typeTitle("Issue using account preferences");
await act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
// Create more keeps the project just used.
expect(target).toHaveBeenLastCalledWith(...(crossProject ? [otherProject.id] : []), expect.objectContaining({ smart_fill: defaultFill }));
});
it("retains the Smart Fill override when a Create more submission fails", async () => {
const dialog = await mountDialog("issue");
dialog.create.mockRejectedValueOnce(new Error("Creation failed"));
await click("Create more toggle");
await click("Smart Fill");
await typeTitle("Manual issue");
for (let attempt = 0; attempt < 2; attempt++) {
await act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
expect(dialog.create).toHaveBeenLastCalledWith(expect.objectContaining({ title: "Manual issue", smart_fill: false }));
}
expect(dialog.create).toHaveBeenCalledTimes(2);
});
it.each(["issue", "objective"] as const)("portals the modal %s relation type menu outside the clipping dialog, then picks the target in the palette, after reopening", async (kind) => {
// Smart Fill owns relations while it is on (MIN-717): pick them by hand.
if (kind === "issue") fixture.smartFillCreated = false;
const dialog = await mountDialog(kind);
for (let opening = 0; opening < 2; opening++) {
await click(en.Relations.addRelationAria);
const input = document.querySelector('[aria-label="Search relations"]')!;
expect(input).not.toBeNull();
expect(input.closest("[data-dialog-content]")).toBeNull();
expect(input.parentElement?.getAttribute("data-modal")).toBe("true");
// The type is never a palette step (MIN-844): only its targets are.
expect(document.querySelector("[data-palette-picker]")).toBeNull();
await click(en.Relations.blocks);
expect(document.querySelector('[aria-label="Search relations"]')).toBeNull();
const search = document.querySelector<HTMLInputElement>(`[aria-label="${PICKER_SEARCH_LABEL}"]`)!;
expect(search.closest("[data-dialog-content]")).toBeNull();
expect(search.placeholder).toBe(en.Relations.pick_blocks);
await click("Close picker");
expect(document.querySelector("[data-palette-picker]")).toBeNull();
await click("Close creation");
await dialog.reopen();
}
});
it("places relations last on the objective page and uses its project key without a project menu", async () => {
await mountDialog("objective", false);
const picker = host.querySelector("[data-picker]")!;
expect(picker.parentElement!.lastElementChild).toBe(picker);
await addRelation("blocks", "issue");
expect(host.textContent).toContain("MIN-12");
expect(host.textContent).not.toContain("Target issue");
});
it("shows relations when the account disables Smart Fill", async () => {
fixture.smartFill = false;
await mountDialog("issue");
expect(host.textContent).not.toContain("Smart Fill");
await addRelation("related", "objective");
expect(host.textContent).toContain("Target objective");
});
it("opens a property hidden by Smart Fill from its shortcut and keeps the value set by hand", async () => {
const dialog = await mountDialog("issue");
const effortTrigger = () => host.querySelector('button[aria-label="Change effort"]');
expect(effortTrigger()).toBeNull();
await act(() => { document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "e", bubbles: true })); });
expect(effortTrigger()).not.toBeNull();
await click("M");
// The picker closed, but the effort chosen by hand stays on the form.
expect(effortTrigger()).not.toBeNull();
expect(host.querySelector('button[aria-label="Change priority"]')).toBeNull();
expect(host.textContent).toContain("Smart Fill");
await typeTitle("Issue with a chosen effort");
await act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
expect(dialog.create).toHaveBeenCalledWith(expect.objectContaining({ effort: "m", priority: "none", smart_fill: true }));
});
it("opens the relations picker hidden by Smart Fill from R and keeps the relation chosen (MIN-749)", async () => {
const dialog = await mountDialog("issue");
const relationButton = () => host.querySelector(`[aria-label="${en.Relations.addRelationAria}"]`);
expect(relationButton()).toBeNull();
await act(() => { document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "r", bubbles: true })); });
expect(relationButton()).not.toBeNull();
expect(document.querySelector('input[aria-label="Search relations"]')).not.toBeNull();
await click(en.Relations.related);
await click("Target objective");
// The picker leaves again with Smart Fill on; the chosen relation stays.
expect(relationButton()).toBeNull();
expect(host.textContent).toContain("Target objective");
await typeTitle("Issue with a chosen relation");
await act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
expect(dialog.create).toHaveBeenCalledWith(expect.objectContaining({ smart_fill: true, relations: [expect.objectContaining({ target_id: objective.id })] }));
});
it("offers Parent of and Sub-issue of in the new-issue form only, with one parent at most (MIN-844)", async () => {
fixture.smartFillCreated = false;
const dialog = await mountDialog("issue");
await click(en.Relations.addRelationAria);
expect(document.body.textContent).toContain(en.Relations.parent_of);
await click(en.Relations.sub_issue_of);
// Issues only: an objective is never a parent.
expect(document.body.textContent).not.toContain("Target objective");
await click("MIN-12Target issue");
expect(host.querySelector(`[aria-label="${en.Relations.sub_issue_of}: MIN-12"]`)).not.toBeNull();
// With a parent chosen, neither hierarchy entry is offered again.
await click(en.Relations.addRelationAria);
expect(document.body.textContent).not.toContain(en.Relations.sub_issue_of);
expect(document.body.textContent).not.toContain(en.Relations.parent_of);
await click(en.Relations.addRelationAria);
await typeTitle("Child issue");
await act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
expect(dialog.create).toHaveBeenCalledWith(expect.objectContaining({
relations: [expect.objectContaining({ type: "sub_issue_of", target_id: issue.id })],
}));
});
it("sends the objective's Smart Fill choice and hides what it fills (MIN-919)", async () => {
const dialog = await mountDialog("objective");
// Smart Fill fills the target date and the color: they leave the row.
expect(host.querySelector("[data-field=date]")).toBeNull();
await typeTitle("Onboarding redesign");
await act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
expect(dialog.create).toHaveBeenLastCalledWith(expect.objectContaining({ name: "Onboarding redesign", smart_fill: true }));
});
it("leaves the objective's name to Smart Fill by its own switch, not the issue title's (MIN-919)", async () => {
fixture.smartFillTitle = true;
fixture.smartFillObjectiveName = false;
await mountDialog("objective");
expect(host.querySelector(".creation-title")).not.toBeNull();
root.unmount();
root = createRoot(host);
fixture.smartFillTitle = false;
fixture.smartFillObjectiveName = true;
await mountDialog("objective");
expect(host.querySelector(".creation-title")).toBeNull();
});
it("brings the objective's fields back when Smart Fill is off for it (MIN-919)", async () => {
const dialog = await mountDialog("objective");
await click("Smart Fill");
expect(host.querySelector("[data-field=date]")).not.toBeNull();
await typeTitle("Onboarding redesign");
await act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
expect(dialog.create).toHaveBeenLastCalledWith(expect.objectContaining({ smart_fill: false }));
});
it("keeps the hierarchy entries out of the new-objective form", async () => {
await mountDialog("objective");
await click(en.Relations.addRelationAria);
expect(document.body.textContent).not.toContain(en.Relations.parent_of);
expect(document.body.textContent).not.toContain(en.Relations.sub_issue_of);
});
it.each(["issue", "objective"] as const)("keeps %s selections when creation is rejected", async (kind) => {
// Smart Fill owns relations while it is on (MIN-717): pick them by hand.
if (kind === "issue") fixture.smartFillCreated = false;
const dialog = await mountDialog(kind);
dialog.create.mockRejectedValueOnce(new Error("Creation rejected"));
await addRelation("blocks", "objective");
await typeTitle("New entity");
await act(() => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
expect(host.querySelector("textarea")?.value).toBe("New entity");
expect(host.textContent).toContain("Target objective");
});
it.each(["issue", "objective"] as const)("submits removable %s pills with the keyboard shortcut", async (kind) => {
// Smart Fill owns an issue's relations (MIN-717) and an objective's target
// date (MIN-919) while it is on: the row this test reads needs it off.
fixture.smartFillCreated = false;
fixture.smartFillObjectives = false;
const dialog = await mountDialog(kind);
await addRelation("blocked_by", "objective");
const pill = host.querySelector(`[aria-label="${en.Relations.blocked_by}: Target objective"]`)!;
expect(pill).not.toBeNull();
// The pills sit in the property row, between the assignee and the date.
expect(pill.compareDocumentPosition(host.querySelector("[data-field=assignee]")!) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
expect(pill.compareDocumentPosition(host.querySelector("[data-field=date]")!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await click(`${en.Common.remove}: ${en.Relations.blocked_by}: Target objective`);
expect(host.querySelector(`[aria-label="${en.Relations.blocked_by}: Target objective"]`)).toBeNull();
await addRelation("blocks", "issue");
await typeTitle("New entity");
await act(() => { host.querySelector("textarea")!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true })); });
expect(dialog.create).toHaveBeenCalledWith(expect.objectContaining({ relations: [{ type: "blocks", target_id: issue.id, target_type: "issue", target_label: "MIN-12 Target issue" }] }));
expect(dialog.createOther).not.toHaveBeenCalled();
await dialog.reopen();
expect(host.textContent).not.toContain("MIN-12 Target issue");
});
it("hides the relation picker while Smart Fill is on, keeps chosen relations, and resets after create more", async () => {
fixture.smartFillCreated = false;
const dialog = await mountDialog("issue");
const relationButton = host.querySelector(`[aria-label="${en.Relations.addRelationAria}"]`)!;
const smartFill = [...host.querySelectorAll("button")].find((button) => button.textContent === "Smart Fill")!;
expect(relationButton.compareDocumentPosition(host.querySelector("[data-field=assignee]")!) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
expect(relationButton.compareDocumentPosition(host.querySelector("[data-field=date]")!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(relationButton.compareDocumentPosition(smartFill) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await addRelation("related", "objective");
// Smart Fill also links related issues after the save (MIN-717): the
// picker leaves, the relation already chosen stays as a pill.
await click("Smart Fill");
expect(host.querySelector(`[aria-label="${en.Relations.addRelationAria}"]`)).toBeNull();
expect(host.textContent).toContain("Target objective");
await click("Create more toggle");
await typeTitle("Smart Fill issue");
await act(() => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
expect(dialog.create).toHaveBeenCalledWith(expect.objectContaining({ smart_fill: true, relations: [expect.objectContaining({ target_id: objective.id })] }));
expect(host.querySelector("textarea")?.value).toBe("");
expect(host.textContent).not.toContain("Target objective");
});
it("keeps the relation picker beside Smart Fill when the account turned relation linking off", async () => {
fixture.smartFillRelations = false;
await mountDialog("issue");
// Smart Fill is on for the issue, but it will not link anything: the field stays manual.
expect(host.querySelector(`[aria-label="${en.Relations.addRelationAria}"]`)).not.toBeNull();
await addRelation("related", "objective");
expect(host.textContent).toContain("Target objective");
});
it.each(["issue", "objective"] as const)("does not keep a closed %s form holding relations only", async (kind) => {
// Only a title, a description or an attachment make a draft.
if (kind === "issue") fixture.smartFillCreated = false;
const dialog = await mountDialog(kind);
await addRelation("related", "objective");
await click("Close creation");
await dialog.reopen();
expect(host.textContent).not.toContain("Target objective");
expect(fixture.save).not.toHaveBeenCalled();
});
it.each(["issue", "objective"] as const)("keeps a closed %s form and saves it as a draft on demand", async (kind) => {
// Smart Fill owns relations while it is on (MIN-717): pick them by hand.
if (kind === "issue") fixture.smartFillCreated = false;
const dialog = await mountDialog(kind);
await addRelation("related", "objective");
await typeTitle("Draft entity");
// Closing asks nothing and keeps the form for the next opening (MIN-776).
await click("Close creation");
expect(fixture.save).not.toHaveBeenCalled();
await dialog.reopen();
expect(host.textContent).toContain("Target objective");
await click(en.Drafts.saveAsDraft);
expect(fixture.save).toHaveBeenCalledWith(expect.objectContaining({ relations: [expect.objectContaining({ target_id: objective.id })] }));
expect(host.querySelector("form")).toBeNull();
await dialog.reopen();
expect(host.querySelector(`[aria-label="${en.Relations.related}: Target objective"]`)).toBeNull();
// Reopened from the drafts tray, the saved form comes back.
await act(() => dialog.close());
await dialog.reopen(fixture.drafts[0].id);
expect(host.textContent).toContain("Target objective");
});
it.each(["issue", "objective"] as const)("saves a %s form left closed for two minutes as a draft", async (kind) => {
vi.useFakeTimers();
try {
const dialog = await mountDialog(kind);
await typeTitle("Unfinished thought");
await click("Close creation");
await act(async () => { vi.advanceTimersByTime(HELD_DRAFT_DELAY_MS - 1); });
expect(fixture.save).not.toHaveBeenCalled();
await act(async () => { vi.advanceTimersByTime(1); });
expect(fixture.save).toHaveBeenCalledWith(expect.objectContaining(kind === "issue" ? { title: "Unfinished thought" } : { name: "Unfinished thought" }));
// The draft took over the content: the next opening starts blank.
await dialog.reopen();
expect(host.querySelector("textarea")!.value).toBe("");
} finally {
vi.useRealTimers();
}
});
it.each(["issue", "objective"] as const)("resumes a %s form reopened within two minutes without saving it", async (kind) => {
vi.useFakeTimers();
try {
const dialog = await mountDialog(kind);
await typeTitle("Still writing");
await click("Close creation");
await act(async () => { vi.advanceTimersByTime(HELD_DRAFT_DELAY_MS / 2); });
await dialog.reopen();
await act(async () => { vi.advanceTimersByTime(HELD_DRAFT_DELAY_MS); });
expect(fixture.save).not.toHaveBeenCalled();
expect(host.querySelector("textarea")!.value).toBe("Still writing");
} finally {
vi.useRealTimers();
}
});
// MIN-942: a dialog closed before its creation finished held the created
// content, saved it as a draft two minutes later and reopened on it.
it.each(["issue", "objective"] as const)("saves no draft of a %s form closed while it was being created", async (kind) => {
vi.useFakeTimers();
try {
if (kind === "issue") fixture.smartFillCreated = false;
const dialog = await mountDialog(kind);
let finish!: () => void;
dialog.create.mockImplementation(() => new Promise<{ id: string }>((resolve) => { finish = () => resolve({ id: "created" }); }));
await typeTitle("Created meanwhile");
await act(() => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
expect(dialog.create).toHaveBeenCalled();
await click("Close creation");
await act(async () => { finish(); });
await act(async () => { vi.advanceTimersByTime(HELD_DRAFT_DELAY_MS); });
expect(fixture.save).not.toHaveBeenCalled();
await dialog.reopen();
expect(host.querySelector("textarea")!.value).toBe("");
} finally {
vi.useRealTimers();
}
});
it.each(["issue", "objective"] as const)("saves no draft of a %s form its caller closes on creation", async (kind) => {
vi.useFakeTimers();
try {
if (kind === "issue") fixture.smartFillCreated = false;
const dialog = await mountDialog(kind);
// The feedback promotion closes the dialog from its own `onCreate`.
dialog.create.mockImplementation(async () => { dialog.close(); return { id: "created" }; });
await typeTitle("Promoted");
await act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
await act(async () => { vi.advanceTimersByTime(HELD_DRAFT_DELAY_MS); });
expect(fixture.save).not.toHaveBeenCalled();
await dialog.reopen();
expect(host.querySelector("textarea")!.value).toBe("");
} finally {
vi.useRealTimers();
}
});
it.each(["issue", "objective"] as const)("resumes a %s form with the caret in its description", async (kind) => {
const dialog = await mountDialog(kind);
// A fresh form starts in the title.
expect(document.activeElement).toBe(host.querySelector("textarea"));
expect(fixture.editorAutoFocus.at(-1)).toBe(false);
await typeTitle("Half-written");
await click("Close creation");
await dialog.reopen();
expect(fixture.editorAutoFocus.at(-1)).toBe(true);
expect(host.querySelector("textarea")!.value).toBe("Half-written");
// Saving it hands the content to the drafts; the next form starts in the title again.
await click(en.Drafts.saveAsDraft);
await dialog.reopen();
expect(fixture.editorAutoFocus.at(-1)).toBe(false);
expect(document.activeElement).toBe(host.querySelector("textarea"));
});
it("shows the description alone while Smart Fill writes the title, and the title on request (MIN-813)", async () => {
fixture.smartFillTitle = true;
await mountDialog("issue");
expect(host.querySelector("textarea")).toBeNull();
expect(fixture.editorAutoFocus.at(-1)).toBe(true);
// "Set manually" → Title brings the field back with the caret in it.
await click("Set title manually");
const title = host.querySelector("textarea")!;
expect(document.activeElement).toBe(title);
expect(title.placeholder).toBe(en.IssueUI.titlePlaceholder);
// Left empty, it leaves again on blur; a typed title keeps it.
await act(() => title.blur());
expect(host.querySelector("textarea")).toBeNull();
await click("Set title manually");
await typeTitle("Named by hand");
await act(() => host.querySelector("textarea")!.blur());
expect(host.querySelector("textarea")?.value).toBe("Named by hand");
// Without Smart Fill, the title is always there.
await typeTitle("");
await click("Smart Fill");
expect(host.querySelector("textarea")).not.toBeNull();
expect(host.textContent).not.toContain("Set title manually");
});
it.each(["issue", "objective"] as const)("saves a held %s form when its dialog unmounts", async (kind) => {
await mountDialog(kind);
await typeTitle("Left behind");
await click("Close creation");
// Leaving the route that mounts the dialog.
await act(() => root.render(null));
expect(fixture.save).toHaveBeenCalledWith(expect.objectContaining(kind === "issue" ? { title: "Left behind" } : { name: "Left behind" }));
});
it.each(["issue", "objective"] as const)("saves a held %s form before opening a tray draft", async (kind) => {
const base = { id: "stored", projectId: project.id, updatedAt: Date.now(), description: "", relations: [] };
fixture.drafts = [kind === "issue"
? { ...base, title: "Stored draft", status: "todo", priority: "none", effort: null, assignee_id: null, objective_id: null, due_date: null, category_ids: [], resources: [] } as IssueDraft
: { ...base, name: "Stored draft", status: "planned", lead_user_id: null, target_date: null, color: null } as ObjectiveDraft];
fixture.save.mockImplementation(async (draft) => { fixture.drafts = [...fixture.drafts, draft]; });
const dialog = await mountDialog(kind);
await typeTitle("Held form");
await click("Close creation");
await dialog.reopen("stored");
expect(fixture.save).toHaveBeenCalledWith(expect.objectContaining(kind === "issue" ? { title: "Held form" } : { name: "Held form" }));
expect(host.querySelector("textarea")!.value).toBe("Stored draft");
});
it.each(["issue", "objective"] as const)("reopens a %s draft's description from its stored document", async (kind) => {
// The Markdown drops a pill's figure (portrait, color); the document keeps it.
const doc = { type: "doc", content: [{ type: "paragraph", content: [{ type: "mention", attrs: { mentionType: "objective", mentionId: objective.id, mentionLabel: "Target objective", color: "#ff0066" } }] }] };
const base = { id: "draft", projectId: project.id, updatedAt: Date.now(), description: `[@Target objective](mention:objective/${objective.id})`, description_doc: doc, relations: [] };
fixture.drafts = [kind === "issue"
? { ...base, title: "Draft", status: "todo", priority: "none", effort: null, assignee_id: null, objective_id: null, due_date: null, category_ids: [], resources: [] } as IssueDraft
: { ...base, name: "Draft", status: "planned", lead_user_id: null, target_date: null, color: null } as ObjectiveDraft];
const dialog = await mountDialog(kind);
expect(fixture.editorDocuments.at(-1)).toBeNull();
await act(() => dialog.close());
await dialog.reopen("draft");
expect(fixture.editorDocuments.at(-1)).toEqual(doc);
});
it("filters duplicate selections by direction and does not fetch candidates while closed", async () => {
const selection: PendingRelationInput = { type: "blocks", target_id: issue.id, target_type: "issue", target_label: "MIN-12 Target issue" };
const props = { projectId: project.id, projectKey: project.key, active: true, value: [selection], onChange: vi.fn() };
await act(() => root.render(withPalette(React.createElement(CreationRelationsCompact, props))));
expect(fixture.queryProjects.every((project) => project === null)).toBe(true);
await click(en.Relations.addRelationAria);
await click(en.Relations.blocks);
expect(document.body.textContent).not.toContain("Target issue");
await click("Close picker");
await click(en.Relations.addRelationAria);
await click(en.Relations.related);
expect(document.body.textContent).toContain("Target issue");
await act(() => root.render(withPalette(React.createElement(CreationRelationsCompact, { ...props, active: false }))));
await act(() => root.render(withPalette(React.createElement(CreationRelationsCompact, props))));
await click(en.Relations.addRelationAria);
expect(document.body.textContent).toContain(en.Relations.blocked_by);
});
});
it.each(["issue", "objective"] as const)("keeps mobile %s dictation beside submission without submitting on Next", async (kind) => {
fixture.mobile = true;
const dialog = await mountDialog(kind);
// One unlabeled recorder, right after the create button in the fixed footer.
const recorder = host.querySelectorAll("[data-prominent-dictation]");
expect(recorder).toHaveLength(1);
expect(recorder[0].getAttribute("data-prominent-dictation")).toBe("false");
const footer = host.querySelector(".creation-form-footer")!;
expect(footer.closest(".creation-form-body")).toBeNull();
const submitRow = footer.querySelector(".creation-submit")!;
expect(submitRow.lastElementChild?.contains(recorder[0])).toBe(true);
expect(submitRow.querySelector('button[type="submit"]')).not.toBeNull();
// "Create in" shares the sheet title's row instead of adding one.
const header = host.querySelector("[data-mobile-sheet-header]")!;
expect(header.textContent).toContain(kind === "issue" ? en.IssueUI.newIssueTitle : en.Objectives.newObjective);
expect(header.querySelector("[data-create-in]")).not.toBeNull();
expect(host.querySelectorAll("[data-create-in]")).toHaveLength(1);
await typeTitle("Mobile draft");
const title = host.querySelector("textarea")!;
await act(() => title.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })));
expect(dialog.create).not.toHaveBeenCalled();
expect(document.activeElement).toBe(host.querySelector(".creation-description [contenteditable=true]"));
await act(() => footer.querySelector<HTMLButtonElement>('button[type="submit"]')!.click());
expect(dialog.create).toHaveBeenCalledOnce();
});
describe("Create in project (MIN-741)", () => {
const submitButton = () => host.querySelector<HTMLButtonElement>('button[type="submit"]')!;
const submitForm = () => act(async () => { host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); });
it("names the identifier the next issue receives and follows the project's counter", async () => {
const dialog = await mountDialog("issue");
expect(submitButton().textContent).toBe("Create MIN-500");
// Another member's issue reserved 500: realtime bumps the counter.
await dialog.setProjects([{ ...project, issue_seq: 500 }, otherProject]);
expect(submitButton().textContent).toBe("Create MIN-501");
await click("Create in Other project");
expect(submitButton().textContent).toBe("Create OTH-10");
});
it.each(["issue", "objective"] as const)("places the %s project select beside the close button, outside the form", async (kind) => {
await mountDialog(kind);
const select = host.querySelectorAll("[data-create-in]");
expect(select).toHaveLength(1);
expect(select[0].closest("form")).toBeNull();
expect(host.querySelector("[data-mobile-sheet-header]")).toBeNull();
});
it("falls back to a generic label without the counter", async () => {
const dialog = await mountDialog("issue");
await dialog.setProjects([{ ...project, issue_seq: undefined }, otherProject]);
expect(submitButton().textContent).toBe(en.IssueUI.createTicket);
});
it("copies the identifier from the split menu while creating", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
const dialog = await mountDialog("issue");
await typeTitle("Copied issue");
await click("Create and copy MIN-500");
expect(writeText).toHaveBeenCalledWith("MIN-500");
expect(dialog.create).toHaveBeenCalledWith(expect.objectContaining({ title: "Copied issue" }));
});
it("clears project-scoped choices when the issue moves to another project", async () => {
fixture.smartFillCreated = false;
const dialog = await mountDialog("issue");
await addRelation("related", "objective");
await click("Create in Other project");
expect(host.textContent).not.toContain("Target objective");
await typeTitle("Moved issue");
await submitForm();
expect(dialog.create).not.toHaveBeenCalled();
expect(dialog.createOther).toHaveBeenCalledWith(otherProject.id, expect.objectContaining({
title: "Moved issue", relations: [], category_ids: [], objective_id: null, parent_id: null, copy_resources: [],
}));
// The dialog closed on creation; reopening starts in its own project.
await dialog.reopen();
expect(host.querySelector("[data-create-in]")?.getAttribute("data-create-in")).toBe(project.id);
});
it("creates an objective with a plain button in the selected project", async () => {
const dialog = await mountDialog("objective");
expect(submitButton().textContent).toBe(en.Objectives.createObjective);
await addRelation("blocks", "issue");
await click("Create in Other project");
expect(host.textContent).not.toContain("MIN-12");
await typeTitle("Moved objective");
await submitForm();
expect(dialog.create).not.toHaveBeenCalled();
expect(dialog.createOther).toHaveBeenCalledWith(otherProject.id, expect.objectContaining({
name: "Moved objective", lead_user_id: null, relations: [], copy_resources: [],
}));
});
});