FN-7302: compact quick-add workflow selector
Keep the Quick Add workflow selector compact without sacrificing readable workflow menu options. - Narrow the closed workflow trigger and truncate only its label so action controls keep room. - Portal and viewport-clamp the expanded workflow menu for right-side columns and mobile layouts. - Preserve readable wrapping for long workflow names and duplicate-id subtitles in the menu. - Cover compact trigger sizing, menu readability, and viewport clamping with QuickEntryBox tests. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7302-quick-add-workflow-trigger.md | 7 ++ .../dashboard/app/components/QuickEntryBox.css | 29 ++++-- .../dashboard/app/components/QuickEntryBox.tsx | 114 ++++++++++++++++++++- .../components/__tests__/QuickEntryBox.test.tsx | 79 +++++++++++++- 4 files changed, 211 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7302 Fusion-Task-Lineage: 4a8042d8-6cc5-4f40-bbcb-71aa5bfb7cd2 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7302-quick-add-workflow-trigger.md
Normal file
7
.changeset/fn-7302-quick-add-workflow-trigger.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Make the Quick Add workflow selector compact while keeping long menu names readable.
|
||||
category: fix
|
||||
dev: Narrows only the closed QuickEntryBox workflow trigger; menu width and workflow routing remain unchanged.
|
||||
@@ -140,14 +140,24 @@ FNXC:QuickAddWorkflow 2026-06-30-00:00:
|
||||
Quick-add workflow targeting sits in the action row as a compact existing-button dropdown. Use tokenized sizing/colors so the selector wraps with Save/Plan controls on narrow Board and List surfaces without introducing a separate visual hierarchy.
|
||||
|
||||
FNXC:QuickAddWorkflow 2026-06-30-12:38:
|
||||
Operators need workflow identity at quick-add time. Keep the trigger wide enough for realistic names, render shared WorkflowIcon output inline, and let no-icon custom workflows collapse without blank icon shells.
|
||||
Operators need workflow identity at quick-add time. Render shared WorkflowIcon output inline and let no-icon custom workflows collapse without blank icon shells.
|
||||
|
||||
FNXC:QuickAddWorkflow 2026-06-30-14:10:
|
||||
The closed selector should be compact enough to leave room for Save/Fast/Subtask controls, while the opened menu stays wide and viewport-bounded so longer workflow names remain readable.
|
||||
|
||||
FNXC:QuickAddWorkflow 2026-06-30-15:05:
|
||||
Long workflow labels must truncate only in the closed trigger. Menu option text wraps inside the viewport-bounded menu so operators can read the full workflow name and duplicate-id subtitle.
|
||||
|
||||
FNXC:QuickAddWorkflow 2026-06-30-16:16:
|
||||
The wide menu is portaled and fixed-positioned by QuickEntryBox so right-side board columns and mobile wrapped action rows clamp to the viewport instead of overflowing from the compact trigger anchor.
|
||||
*/
|
||||
.quick-entry-workflow-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 1 1 calc(var(--space-xl) * 8);
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: min(calc(var(--space-xl) * 11), 100%);
|
||||
width: fit-content;
|
||||
max-width: min(calc(var(--space-xl) * 5), 100%);
|
||||
}
|
||||
|
||||
.quick-entry-workflow-trigger {
|
||||
@@ -155,9 +165,9 @@ Operators need workflow identity at quick-add time. Keep the trigger wide enough
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: min(calc(var(--space-xl) * 11), calc(100vw - var(--space-lg)));
|
||||
max-width: min(calc(var(--space-xl) * 5), calc(100vw - var(--space-lg)));
|
||||
}
|
||||
|
||||
.quick-entry-workflow-icon {
|
||||
@@ -172,9 +182,7 @@ Operators need workflow identity at quick-add time. Keep the trigger wide enough
|
||||
}
|
||||
|
||||
.quick-entry-workflow-menu {
|
||||
position: absolute;
|
||||
inset-block-start: calc(100% + var(--space-xs));
|
||||
inset-inline-start: 0;
|
||||
position: fixed;
|
||||
width: min(calc(var(--space-xl) * 16), calc(100vw - var(--space-lg)));
|
||||
min-width: min(calc(var(--space-xl) * 14), calc(100vw - var(--space-lg)));
|
||||
max-width: calc(100vw - var(--space-lg));
|
||||
@@ -203,9 +211,8 @@ Operators need workflow identity at quick-add time. Keep the trigger wide enough
|
||||
.quick-entry-workflow-option-id {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.quick-entry-workflow-option-id {
|
||||
|
||||
@@ -196,6 +196,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
);
|
||||
const realWorkflowOptions = useMemo(() => getRealWorkflowOptions(workflowOptions), [workflowOptions]);
|
||||
const workflowPickerRef = useRef<HTMLDivElement>(null);
|
||||
const workflowTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const workflowPickerPortalRef = useRef<HTMLDivElement>(null);
|
||||
const [workflowPickerPosition, setWorkflowPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||||
const previousWorkflowDefaultRef = useRef<{ workflowId: string | null | undefined; defaultWorkflowId: string | null | undefined }>({ workflowId, defaultWorkflowId });
|
||||
const [showWorkflowPicker, setShowWorkflowPicker] = useState(false);
|
||||
/*
|
||||
@@ -594,7 +597,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (workflowPickerRef.current?.contains(target)) return;
|
||||
if (workflowPickerPortalRef.current?.contains(target)) return;
|
||||
setShowWorkflowPicker(false);
|
||||
setWorkflowPickerPosition(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
@@ -877,6 +882,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
if (showWorkflowPicker) {
|
||||
setShowWorkflowPicker(false);
|
||||
setWorkflowPickerPosition(null);
|
||||
return;
|
||||
}
|
||||
if (showAgentPicker) {
|
||||
@@ -1060,6 +1066,56 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
});
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
const updateWorkflowPickerPosition = useCallback(() => {
|
||||
const trigger = workflowTriggerRef.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 = Math.min(viewportHeight * (isMobile ? 0.6 : 0.5), 360);
|
||||
/*
|
||||
FNXC:QuickAddWorkflow 2026-06-30-16:16:
|
||||
The workflow menu is wider than its compact trigger, so portal and clamp it against the visual viewport instead of anchoring it to the trigger's inline start. This keeps right-side Board columns and wrapped mobile action rows readable without horizontal overflow.
|
||||
*/
|
||||
const preferredWidth = isMobile
|
||||
? Math.min(viewportWidth - horizontalPadding * 2, 448)
|
||||
: Math.min(Math.max(rect.width * 3, 448), 512);
|
||||
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, 180);
|
||||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 180);
|
||||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||||
const maxHeight = Math.max(
|
||||
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
|
||||
180,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
setWorkflowPickerPosition({ top, left, width, maxHeight });
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
const updateDepDropdownPosition = useCallback(() => {
|
||||
const trigger = depTriggerRef.current;
|
||||
if (!trigger) return;
|
||||
@@ -1337,6 +1393,31 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
};
|
||||
}, [isRefineMenuOpen, updateRefineMenuPosition]);
|
||||
|
||||
// Keep workflow picker portal anchored during scroll/resize
|
||||
useEffect(() => {
|
||||
if (!showWorkflowPicker) return;
|
||||
|
||||
const handleReposition = () => updateWorkflowPickerPosition();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, [showWorkflowPicker, updateWorkflowPickerPosition]);
|
||||
|
||||
// Keep dependency dropdown portal anchored during scroll/resize
|
||||
useEffect(() => {
|
||||
if (!showDeps) return;
|
||||
@@ -1688,6 +1769,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
{showWorkflowSelector && (
|
||||
<div className="quick-entry-workflow-wrap" ref={workflowPickerRef}>
|
||||
<button
|
||||
ref={workflowTriggerRef}
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger quick-entry-workflow-trigger"
|
||||
data-testid="quick-entry-workflow-trigger"
|
||||
@@ -1705,12 +1787,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setIsModelMenuOpen(false);
|
||||
setModelMenuPosition(null);
|
||||
setActiveModelSubmenu(null);
|
||||
setShowWorkflowPicker((prev) => !prev);
|
||||
setShowWorkflowPicker((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
updateWorkflowPickerPosition();
|
||||
} else {
|
||||
setWorkflowPickerPosition(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowWorkflowPicker(false);
|
||||
setWorkflowPickerPosition(null);
|
||||
}
|
||||
}}
|
||||
title={t("tasks.quickEntryWorkflowTitle", "Workflow for the next task")}
|
||||
@@ -1724,8 +1815,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
<span className="quick-entry-workflow-label">{quickEntryWorkflowLabel}</span>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</button>
|
||||
{showWorkflowPicker && (
|
||||
<div className="dep-dropdown quick-entry-workflow-menu" role="listbox" data-testid="quick-entry-workflow-menu">
|
||||
{showWorkflowPicker && portalRoot && workflowPickerPosition && createPortal(
|
||||
<div
|
||||
ref={workflowPickerPortalRef}
|
||||
className="dep-dropdown quick-entry-workflow-menu"
|
||||
role="listbox"
|
||||
data-testid="quick-entry-workflow-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${workflowPickerPosition.top}px`,
|
||||
left: `${workflowPickerPosition.left}px`,
|
||||
width: `${workflowPickerPosition.width}px`,
|
||||
maxHeight: workflowPickerPosition.maxHeight ? `${workflowPickerPosition.maxHeight}px` : undefined,
|
||||
overflowY: workflowPickerPosition.maxHeight ? "auto" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="dep-dropdown-search-header">{t("tasks.quickEntryWorkflowHeader", "Create in workflow")}</div>
|
||||
{realWorkflowOptions.map((option) => {
|
||||
const duplicateName = (quickEntryWorkflowNameCounts.get(option.name) ?? 0) > 1;
|
||||
@@ -1745,6 +1849,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
onClick={() => {
|
||||
setQuickEntryWorkflowId(option.id);
|
||||
setShowWorkflowPicker(false);
|
||||
setWorkflowPickerPosition(null);
|
||||
}}
|
||||
>
|
||||
<WorkflowIcon workflowId={option.id} icon={option.icon} className="quick-entry-workflow-icon" decorative />
|
||||
@@ -1755,7 +1860,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
portalRoot,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1699,17 +1699,90 @@ describe("QuickEntryBox", () => {
|
||||
expect(screen.getByTestId("quick-entry-actions").querySelector(".quick-entry-workflow-wrap")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses tokenized responsive CSS for the wider workflow selector", () => {
|
||||
it("keeps long workflow names readable in the menu while the trigger uses a truncation label", () => {
|
||||
const longName = "Long workflow name that should remain readable in the opened menu";
|
||||
renderQuickEntryBox({
|
||||
workflowId: "wf-long",
|
||||
defaultWorkflowId: "wf-default",
|
||||
workflowOptions: [
|
||||
{ id: "wf-default", name: "Coding", columns: [] },
|
||||
{ id: "wf-long", name: longName, icon: "🧭", columns: [] },
|
||||
],
|
||||
});
|
||||
|
||||
const trigger = screen.getByTestId("quick-entry-workflow-trigger");
|
||||
const triggerLabel = trigger.querySelector(".quick-entry-workflow-label");
|
||||
expect(triggerLabel).toHaveTextContent(longName);
|
||||
expect(triggerLabel).toHaveClass("quick-entry-workflow-label");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByTestId("quick-entry-workflow-menu")).toHaveTextContent(longName);
|
||||
const optionName = screen.getByTestId("quick-entry-workflow-option-wf-long").querySelector(".quick-entry-workflow-option-name");
|
||||
expect(optionName).toHaveTextContent(longName);
|
||||
expect(optionName).toHaveClass("quick-entry-workflow-option-name");
|
||||
});
|
||||
|
||||
it("clamps the wider workflow menu within the viewport from right-side triggers", () => {
|
||||
const viewportWidth = 800;
|
||||
vi.spyOn(window, "innerWidth", "get").mockReturnValue(viewportWidth);
|
||||
vi.spyOn(window, "innerHeight", "get").mockReturnValue(720);
|
||||
renderQuickEntryBox({
|
||||
workflowId: "wf-long",
|
||||
defaultWorkflowId: "wf-default",
|
||||
workflowOptions: [
|
||||
{ id: "wf-default", name: "Coding", columns: [] },
|
||||
{ id: "wf-long", name: "A long readable workflow name", icon: "🧭", columns: [] },
|
||||
],
|
||||
});
|
||||
|
||||
const trigger = screen.getByTestId("quick-entry-workflow-trigger");
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 720,
|
||||
y: 100,
|
||||
width: 64,
|
||||
height: 32,
|
||||
top: 100,
|
||||
right: 784,
|
||||
bottom: 132,
|
||||
left: 720,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
const menu = screen.getByTestId("quick-entry-workflow-menu");
|
||||
const left = parseFloat(menu.style.left);
|
||||
const width = parseFloat(menu.style.width);
|
||||
|
||||
expect(menu.style.position).toBe("fixed");
|
||||
expect(left).toBeGreaterThanOrEqual(16);
|
||||
expect(left + width).toBeLessThanOrEqual(viewportWidth - 16);
|
||||
expect(width).toBeGreaterThan(64);
|
||||
});
|
||||
|
||||
it("uses tokenized responsive CSS for a compact workflow trigger and readable menu", () => {
|
||||
const wrapRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-wrap");
|
||||
const triggerRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-trigger");
|
||||
const labelRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-label");
|
||||
const menuRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-menu");
|
||||
const optionCopyRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-option-copy");
|
||||
const optionNameRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-option-name,\n.quick-entry-workflow-option-id");
|
||||
|
||||
expect(triggerRule).toContain("max-width: min(calc(var(--space-xl) * 11), calc(100vw - var(--space-lg)))");
|
||||
expect(wrapRule).toContain("flex: 0 1 auto");
|
||||
expect(wrapRule).toContain("width: fit-content");
|
||||
expect(wrapRule).toContain("max-width: min(calc(var(--space-xl) * 5), 100%)");
|
||||
expect(triggerRule).toContain("width: auto");
|
||||
expect(triggerRule).toContain("max-width: min(calc(var(--space-xl) * 5), calc(100vw - var(--space-lg)))");
|
||||
expect(triggerRule).toContain("gap: var(--space-xs)");
|
||||
expect(labelRule).toContain("text-overflow: ellipsis");
|
||||
expect(menuRule).toContain("position: fixed");
|
||||
expect(menuRule).not.toContain("inset-inline-start: 0");
|
||||
expect(menuRule).toContain("width: min(calc(var(--space-xl) * 16), calc(100vw - var(--space-lg)))");
|
||||
expect(menuRule).toContain("min-width: min(calc(var(--space-xl) * 14), calc(100vw - var(--space-lg)))");
|
||||
expect(optionCopyRule).toContain("gap: var(--space-2xs)");
|
||||
expect(`${triggerRule}\n${menuRule}\n${optionCopyRule}`).not.toMatch(/#[0-9a-f]{3,8}|rgb\(|\d+px/i);
|
||||
expect(optionNameRule).toContain("overflow-wrap: anywhere");
|
||||
expect(optionNameRule).toContain("white-space: normal");
|
||||
expect(optionNameRule).not.toContain("text-overflow: ellipsis");
|
||||
expect(`${wrapRule}\n${triggerRule}\n${labelRule}\n${menuRule}\n${optionCopyRule}\n${optionNameRule}`).not.toMatch(/#[0-9a-f]{3,8}|rgb\(|\d+px/i);
|
||||
expect(QUICK_ENTRY_BOX_CSS).toMatch(/@media \(max-width: 768px\) \{[\s\S]*?quick-entry-workflow-menu[\s\S]*?min-width: min\(calc\(var\(--space-xl\) \* 11\), calc\(100vw - var\(--space-lg\)\)\)/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user