Ctrl K
ComunidadGuías…

lib/creation-relations-dialogs.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// @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: [],
    }));
  });
});