feat(FN-1480): merge fusion/fn-1480
This commit is contained in:
@@ -118,6 +118,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
|
||||
const agentPickerRef = useRef<HTMLDivElement>(null);
|
||||
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||||
// Dependency dropdown portal refs and state
|
||||
const depTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const depDropdownPortalRef = useRef<HTMLDivElement>(null);
|
||||
const [depDropdownPosition, setDepDropdownPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||||
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
@@ -677,6 +681,66 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
});
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
const updateDepDropdownPosition = useCallback(() => {
|
||||
const trigger = depTriggerRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||||
const horizontalPadding = 16;
|
||||
const verticalPadding = 16;
|
||||
const gap = 4;
|
||||
const isMobile = viewportWidth <= 768;
|
||||
|
||||
const preferredHeight = isMobile
|
||||
? Math.min(viewportHeight * 0.6, 320)
|
||||
: Math.min(viewportHeight * 0.5, 320);
|
||||
|
||||
// Wider dropdown for dependency selection - easier to read task names
|
||||
const preferredWidth = isMobile
|
||||
? Math.min(viewportWidth - horizontalPadding * 2, 360)
|
||||
: Math.max(rect.width, 280);
|
||||
|
||||
const width = Math.min(
|
||||
preferredWidth,
|
||||
Math.max(viewportWidth - horizontalPadding * 2, 240),
|
||||
);
|
||||
|
||||
const triggerTop = rect.top - offsetTop;
|
||||
const triggerBottom = rect.bottom - offsetTop;
|
||||
const triggerLeft = rect.left - offsetLeft;
|
||||
|
||||
const spaceBelow = viewportHeight - triggerBottom;
|
||||
const spaceAbove = triggerTop;
|
||||
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 200);
|
||||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 200);
|
||||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||||
|
||||
const maxHeight = Math.max(
|
||||
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
|
||||
200,
|
||||
);
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(triggerLeft, horizontalPadding),
|
||||
viewportWidth - horizontalPadding - width,
|
||||
) + offsetLeft;
|
||||
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||||
: Math.min(
|
||||
triggerBottom + gap + offsetTop,
|
||||
viewportHeight + offsetTop - verticalPadding - maxHeight,
|
||||
);
|
||||
|
||||
setDepDropdownPosition({
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
});
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
// Keep model menu portal anchored during scroll/resize
|
||||
useEffect(() => {
|
||||
if (!isModelMenuOpen) return;
|
||||
@@ -727,6 +791,31 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
};
|
||||
}, [isRefineMenuOpen, updateRefineMenuPosition]);
|
||||
|
||||
// Keep dependency dropdown portal anchored during scroll/resize
|
||||
useEffect(() => {
|
||||
if (!showDeps) return;
|
||||
|
||||
const handleReposition = () => updateDepDropdownPosition();
|
||||
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", handleReposition);
|
||||
vv.addEventListener("scroll", handleReposition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", handleReposition);
|
||||
vv.removeEventListener("scroll", handleReposition);
|
||||
}
|
||||
};
|
||||
}, [showDeps, updateDepDropdownPosition]);
|
||||
|
||||
const handlePlanningModelChange = useCallback((value: string) => {
|
||||
const next = parseModelSelection(value);
|
||||
setPlanningProvider(next.provider);
|
||||
@@ -1045,6 +1134,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
|
||||
<div className="dep-trigger-wrap">
|
||||
<button
|
||||
ref={depTriggerRef}
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
data-testid="quick-entry-deps"
|
||||
@@ -1056,6 +1146,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setModelMenuPosition(null);
|
||||
setActiveModelSubmenu(null);
|
||||
setShowAgentPicker(false);
|
||||
// Position the dropdown before rendering
|
||||
updateDepDropdownPosition();
|
||||
} else {
|
||||
setDepDropdownPosition(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -1064,51 +1158,65 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
<Link size={12} style={{ verticalAlign: "middle" }} />
|
||||
{dependencies.length > 0 ? `${dependencies.length} deps` : "Deps"}
|
||||
</button>
|
||||
{showDeps && (() => {
|
||||
const term = depSearch.toLowerCase();
|
||||
const filtered = (term
|
||||
? tasks.filter((t) =>
|
||||
t.id.toLowerCase().includes(term) ||
|
||||
(t.title && t.title.toLowerCase().includes(term)) ||
|
||||
(t.description && t.description.toLowerCase().includes(term))
|
||||
)
|
||||
: [...tasks]
|
||||
).sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
return bNum - aNum;
|
||||
});
|
||||
return (
|
||||
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No existing tasks</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggleDep(t.id)}
|
||||
>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{/* Dependency dropdown rendered via portal for proper viewport positioning */}
|
||||
{showDeps && portalRoot && depDropdownPosition && (() => {
|
||||
const term = depSearch.toLowerCase();
|
||||
const filtered = (term
|
||||
? tasks.filter((t) =>
|
||||
t.id.toLowerCase().includes(term) ||
|
||||
(t.title && t.title.toLowerCase().includes(term)) ||
|
||||
(t.description && t.description.toLowerCase().includes(term))
|
||||
)
|
||||
: [...tasks]
|
||||
).sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
return bNum - aNum;
|
||||
});
|
||||
return createPortal(
|
||||
<div
|
||||
ref={depDropdownPortalRef}
|
||||
className="dep-dropdown dep-dropdown--portal"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${depDropdownPosition.top}px`,
|
||||
left: `${depDropdownPosition.left}px`,
|
||||
width: `${depDropdownPosition.width}px`,
|
||||
maxHeight: depDropdownPosition.maxHeight ? `${depDropdownPosition.maxHeight}px` : undefined,
|
||||
overflowY: depDropdownPosition.maxHeight ? "auto" : undefined,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No existing tasks</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggleDep(t.id)}
|
||||
>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 60)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>,
|
||||
portalRoot,
|
||||
);
|
||||
})()}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -815,6 +815,105 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependency dropdown readability (FN-1480)", () => {
|
||||
it("renders dependency dropdown with portal class for viewport escaping", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
openDepsMenu();
|
||||
|
||||
const dropdown = document.querySelector(".dep-dropdown");
|
||||
expect(dropdown).toBeTruthy();
|
||||
// Portal version should have the --portal modifier class
|
||||
expect(dropdown?.classList.contains("dep-dropdown--portal")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows long task titles without aggressive truncation", () => {
|
||||
const longTitleTask: Task = {
|
||||
id: "FN-999",
|
||||
title: "This is a very long task title that exceeds the previous truncation limit and should now be more readable",
|
||||
description: "Task description",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-04-10T00:00:00Z",
|
||||
updatedAt: "2026-04-10T00:00:00Z",
|
||||
};
|
||||
|
||||
renderQuickEntryBox({ tasks: [longTitleTask] });
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Task with long dep" } });
|
||||
openDepsMenu();
|
||||
|
||||
const titleSpan = document.querySelector(".dep-dropdown-title");
|
||||
expect(titleSpan).toBeTruthy();
|
||||
// The new truncation limit is 60 characters, so a long title should be truncated
|
||||
// but with ellipsis, showing it's now readable (not cut off at 30)
|
||||
const titleText = titleSpan?.textContent || "";
|
||||
expect(titleText.length).toBe(60 + 1); // 60 chars + ellipsis
|
||||
expect(titleText).toContain("…");
|
||||
// Verify the content is from the title, not just the id
|
||||
expect(titleText.startsWith("This is a very long task title")).toBe(true);
|
||||
});
|
||||
|
||||
it("closes dependency dropdown on Escape key", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
openDepsMenu();
|
||||
|
||||
// Dropdown should be open
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
|
||||
// Press Escape
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
// Dropdown should be closed
|
||||
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not close dependency dropdown when clicking inside the dropdown", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
openDepsMenu();
|
||||
|
||||
// Dropdown should be open
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
|
||||
// Click on the search input inside the dropdown
|
||||
const searchInput = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
expect(searchInput).toBeTruthy();
|
||||
fireEvent.click(searchInput);
|
||||
|
||||
// Dropdown should still be open
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes dependency dropdown when switching to other controls", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
openDepsMenu();
|
||||
|
||||
// Dropdown should be open
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
|
||||
// Click the models button
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models"));
|
||||
|
||||
// Dropdown should be closed
|
||||
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onPlanningMode and clears input when Plan clicked", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const { props } = renderQuickEntryBox({ onPlanningMode });
|
||||
|
||||
@@ -5754,14 +5754,22 @@ body {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
max-height: 240px;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
z-index: 200;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Portal-rendered dependency dropdown - uses fixed positioning from JS */
|
||||
.dep-dropdown--portal {
|
||||
position: fixed !important;
|
||||
top: auto;
|
||||
left: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dep-dropdown-search-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -25876,7 +25884,6 @@ html .column.drag-over * {
|
||||
|
||||
.quick-entry-box .dep-dropdown {
|
||||
max-width: calc(100vw - 32px);
|
||||
left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user