Files
fusion/packages/dashboard/app/__tests__/quick-entry-workflow-trigger-height.test.tsx
gsxdsm 60b8b4e4c5 FN-7805: redesign quick-add priority indicators as icon-only controls
Redesigns the Quick Add composer's priority selector to icon-only glyph buttons and relocates GitHub tracking beside attach, backed by a new shared priorityIndicator helper.

- Add packages/dashboard/app/utils/priorityIndicator.tsx as the single source of ArrowUp/ArrowDown/Flag/TriangleAlert priority glyphs, shared by QuickEntryBox and TaskForm
- Rework QuickEntryBox.tsx to render icon-only priority/Fast controls and move GitHub tracking + Priority into the .quick-entry-primary-group, with matching QuickEntryBox.css layout updates
- Update TaskForm.tsx to reuse the new priorityIndicator helper instead of duplicating icon/label logic
- Update quick-entry height-parity/trigger-height tests and QuickEntryBox/TaskForm/NewTaskModal tests for the new icon-only layout
- Add priorityIndicator.test.tsx covering the shared helper
- Update docs/dashboard-guide.md and add changeset fn-7805-quick-add-priority-indicators.md (patch, feature)

Files changed:
 .../fn-7805-quick-add-priority-indicators.md       |   7 +
 docs/dashboard-guide.md                            |   3 +-
 .../quick-entry-action-row-height-parity.test.tsx  |  13 +-
 .../quick-entry-workflow-trigger-height.test.tsx   |  10 +-
 .../dashboard/app/components/QuickEntryBox.css     |  19 +-
 .../dashboard/app/components/QuickEntryBox.tsx     | 197 +++++++++++----------
 packages/dashboard/app/components/TaskForm.tsx     |  33 ++--
 .../app/components/__tests__/NewTaskModal.test.tsx |   3 +
 .../components/__tests__/QuickEntryBox.test.tsx    | 113 ++++++++----
 .../app/components/__tests__/TaskForm.test.tsx     |   3 +
 .../app/utils/__tests__/priorityIndicator.test.tsx |  21 +++
 packages/dashboard/app/utils/priorityIndicator.tsx |  30 ++++
 12 files changed, 300 insertions(+), 152 deletions(-)

Fusion-Task-Id: FN-7805

Fusion-Task-Lineage: 404fe8d6-0851-44cb-b31d-f47896aaaaba

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-10 22:25:21 -07:00

424 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { loadAllAppCss, loadStylesCss } from "../test/cssFixture";
import { render, screen, act } from "@testing-library/react";
import { QuickEntryBox } from "../components/QuickEntryBox";
import type { Task } from "@fusion/core";
import type { BoardWorkflowDefinition } from "../api";
import { fetchAgents } from "../api";
/*
FNXC:QuickAddWorkflow 2026-07-08-00:00:
FN-7677 regression coverage. The workflow trigger shares `.dep-trigger` with
InlineCreateCard/NewTaskModal/TaskDetailModal/TaskForm, whose global rule sets
`padding: 3px 8px` and overrides `.btn-sm`'s `padding: 4px 10px`. That made the
quick-add trigger ~2px shorter than the sibling Save/Fast/Subtask `.btn.btn-sm`
buttons. These tests assert the local `.quick-entry-workflow-trigger` override
re-asserts `.btn-sm`'s own padding value (not a new hardcoded literal) so the
box heights resolve equal, and that the fix holds at desktop widths (not just
inside the mobile touch-target `min-height` media block), and that the shared
global `.dep-trigger` rule itself remains untouched for other surfaces.
*/
const mockTasks: Task[] = [
{
id: "FN-001",
title: "Test task 1",
description: "First test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
},
];
const WORKFLOW_A: BoardWorkflowDefinition = {
id: "builtin:coding",
name: "Coding",
columns: [],
};
const WORKFLOW_B: BoardWorkflowDefinition = {
id: "wf-custom-long-name",
name: "A Rather Long Custom Workflow Name That Should Truncate",
columns: [],
};
vi.mock("../api", () => ({
fetchModels: vi.fn().mockResolvedValue({
models: [],
favoriteProviders: [],
favoriteModels: [],
}),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
groupOverlappingFiles: true,
autoMerge: true,
}),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
uploadAttachment: vi.fn().mockResolvedValue({}),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
}));
vi.mock("lucide-react", () => ({
Link: () => null,
Paperclip: () => null,
Brain: () => null,
Lightbulb: () => null,
ListTree: () => null,
Sparkles: () => null,
Save: () => null,
X: () => null,
ChevronDown: () => null,
ChevronUp: () => null,
ChevronRight: () => null,
Bot: () => null,
Server: () => null,
ArrowDown: () => null,
ArrowUp: () => null,
Flag: () => null,
TriangleAlert: () => null,
Zap: () => null,
Maximize2: () => null,
Minimize2: () => null,
}));
vi.mock("../components/ModelSelectionModal", () => ({
ModelSelectionModal: () => null,
}));
vi.mock("../components/CustomModelDropdown", () => ({
CustomModelDropdown: ({
value,
label,
}: {
value: string;
onChange: (value: string) => void;
label: string;
}) => <div data-testid={`mock-dropdown-${label}`}>{value || "none"}</div>,
}));
function mockDesktopViewport() {
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
function renderQuickEntryBox(props: Record<string, unknown> = {}) {
const defaultProps = {
onCreate: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
tasks: mockTasks,
projectId: "test-proj",
workflowId: WORKFLOW_A.id,
defaultWorkflowId: WORKFLOW_A.id,
workflowOptions: [WORKFLOW_A, WORKFLOW_B],
};
return render(<QuickEntryBox {...defaultProps} {...props} />);
}
describe("quick-entry-workflow-trigger height parity (FN-7677)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorage.clear();
vi.mocked(fetchAgents).mockResolvedValue([]);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
writable: true,
value: vi.fn(() => "blob:mock"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
writable: true,
value: vi.fn(),
});
});
afterEach(async () => {
await act(async () => {
vi.runOnlyPendingTimers();
});
vi.useRealTimers();
localStorage.clear();
});
it("renders the workflow trigger and Save button as siblings in the same action row when ≥2 workflow options exist", () => {
mockDesktopViewport();
renderQuickEntryBox();
const trigger = screen.getByTestId("quick-entry-workflow-trigger");
const saveButton = screen.getByTestId("quick-entry-save");
expect(trigger.classList.contains("btn")).toBe(true);
expect(trigger.classList.contains("btn-sm")).toBe(true);
expect(trigger.classList.contains("dep-trigger")).toBe(true);
expect(saveButton.classList.contains("btn")).toBe(true);
expect(saveButton.classList.contains("btn-sm")).toBe(true);
// Both controls must live in the same .quick-entry-actions row.
const actionsRow = trigger.closest(".quick-entry-actions");
expect(actionsRow).not.toBeNull();
expect(actionsRow?.contains(saveButton)).toBe(true);
});
it("does not render a workflow trigger when fewer than 2 real workflow options exist (no layout regression)", () => {
mockDesktopViewport();
renderQuickEntryBox({ workflowOptions: [WORKFLOW_A] });
expect(screen.queryByTestId("quick-entry-workflow-trigger")).toBeNull();
expect(screen.getByTestId("quick-entry-save")).toBeInTheDocument();
});
it("tokenizes .quick-entry-workflow-trigger's own padding rather than inheriting the shorter shared .dep-trigger padding (FN-7682)", () => {
const cssContent = loadAllAppCss();
// .btn-sm establishes the padding contract the Save/Fast/Subtask buttons resolve.
const btnSmMatch = cssContent.match(/\.btn-sm\s*\{[^}]*padding:\s*([^;]+);/);
expect(btnSmMatch).not.toBeNull();
const btnSmPadding = btnSmMatch![1].trim();
expect(btnSmPadding).toBe("4px 10px");
// The shared global .dep-trigger rule sets a shorter padding and MUST remain
// untouched — other surfaces (InlineCreateCard, NewTaskModal, TaskDetailModal,
// TaskForm) still depend on its 3px/8px sizing.
const depTriggerMatch = cssContent.match(
/\.dep-trigger,\s*\n\s*\.inline-create-model-trigger\s*\{[^}]*padding:\s*([^;]+);/,
);
expect(depTriggerMatch).not.toBeNull();
expect(depTriggerMatch![1].trim()).toBe("3px 8px");
// .quick-entry-workflow-trigger must locally re-assert its OWN padding
// (FN-7682 tokenized it to var(--space-sm) var(--space-md), replacing the
// earlier raw "4px 10px" literal) so it does not fall back to the shared
// .dep-trigger's shorter 3px/8px padding. Since FN-7683, box HEIGHT parity
// no longer depends on this padding value matching .btn-sm's literal —
// the FN-7683 fixed min-height==max-height contract on
// `.quick-entry-actions .btn` normalizes resolved height regardless of
// padding — so this only asserts the padding is tokenized and distinct
// from the shared .dep-trigger override, not literal parity with .btn-sm.
const triggerMatch = cssContent.match(
/\.quick-entry-workflow-trigger\s*\{[^}]*padding:\s*([^;]+);/,
);
expect(triggerMatch).not.toBeNull();
const triggerPadding = triggerMatch![1].trim();
expect(triggerPadding).toBe("var(--space-sm) var(--space-md)");
expect(triggerPadding).not.toBe(depTriggerMatch![1].trim());
});
it("keeps the workflow-trigger padding override in the base (non-media-query) rule so desktop widths are covered too, not only the ≤768px touch-target block", () => {
const cssContent = loadAllAppCss();
// Strip everything inside @media blocks to isolate base/desktop rules.
const withoutMediaBlocks = cssContent.replace(/@media[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, "");
const baseTriggerMatch = withoutMediaBlocks.match(
/\.quick-entry-workflow-trigger\s*\{[^}]*padding:\s*([^;]+);/,
);
expect(baseTriggerMatch).not.toBeNull();
expect(baseTriggerMatch![1].trim()).toBe("var(--space-sm) var(--space-md)");
});
it("does not modify the shared global .dep-trigger rule's selector list (InlineCreateCard/NewTaskModal/TaskDetailModal/TaskForm still share it)", () => {
const stylesCssContent = loadStylesCss();
const depTriggerMatches = stylesCssContent.match(/\.dep-trigger,\s*\n\s*\.inline-create-model-trigger\s*\{/g);
expect(depTriggerMatches).not.toBeNull();
expect(depTriggerMatches!.length).toBe(1);
});
it("keeps the workflow icon, truncating label, and chevron intact when the trigger renders (long label)", () => {
mockDesktopViewport();
renderQuickEntryBox({ workflowOptions: [WORKFLOW_A, WORKFLOW_B], workflowId: WORKFLOW_B.id, defaultWorkflowId: WORKFLOW_B.id });
const trigger = screen.getByTestId("quick-entry-workflow-trigger");
const label = trigger.querySelector(".quick-entry-workflow-label");
expect(label).not.toBeNull();
expect(label?.textContent).toBeTruthy();
// Chevron rendered via mocked lucide-react (ChevronDown -> null), so assert
// the trigger still has non-empty content (icon slot + label), i.e. it is
// not an empty shell.
expect(trigger.textContent?.length ?? 0).toBeGreaterThan(0);
});
});
/*
FNXC:QuickAddActionRow 2026-07-08-10:15:
FN-7683 regression coverage. FN-7680 gave `.quick-entry-actions .btn,
.quick-entry-actions .wf-optional-steps-dropdown-trigger` a bare `min-height`
floor, which only guarantees a control is AT LEAST that tall — it cannot clamp
a variant (e.g. Save's text + inline `<Save size={12}>` icon content) whose
natural content box already resolves TALLER than the floor back down to match
shorter siblings. These tests assert the upgraded contract declares a FIXED
box height (min-height paired with an EQUAL max-height, not a bare floor) plus
centered, tokenized line-height, at BOTH the desktop base rule and the
≤768px touch-target media block (FN-5751: never a breakpoint-only fix). jsdom
does not run a real layout engine (`getBoundingClientRect` returns zeros and
`calc()`/`var()` are unresolved there), so parity is asserted at the CSS
declaration level — not via pixel measurement — plus a rendered-DOM check that
the controls remain siblings in one `.quick-entry-actions` row.
*/
describe("quick-entry-actions fixed-height parity, not just a min-height floor (FN-7683)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorage.clear();
vi.mocked(fetchAgents).mockResolvedValue([]);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
writable: true,
value: vi.fn(() => "blob:mock"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
writable: true,
value: vi.fn(),
});
});
afterEach(async () => {
await act(async () => {
vi.runOnlyPendingTimers();
});
vi.useRealTimers();
localStorage.clear();
});
it("declares a FIXED box height (min-height paired with an equal max-height) on the desktop base rule, not a bare min-height floor", () => {
const cssContent = loadAllAppCss();
const withoutMediaBlocks = cssContent.replace(/@media[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, "");
const baseRuleMatch = withoutMediaBlocks.match(
/\.quick-entry-actions \.btn,\s*\n\.quick-entry-actions \.wf-optional-steps-dropdown-trigger \{([^}]*)\}/,
);
expect(baseRuleMatch).not.toBeNull();
const baseRuleBody = baseRuleMatch![1];
const minHeightMatch = baseRuleBody.match(/min-height:\s*([^;]+);/);
const maxHeightMatch = baseRuleBody.match(/max-height:\s*([^;]+);/);
expect(minHeightMatch).not.toBeNull();
expect(maxHeightMatch).not.toBeNull();
// The defect this task fixes: a bare min-height floor with no max-height
// cannot clamp a taller variant back down. Assert max-height is present
// AND equal to min-height, i.e. a true fixed box, not merely a floor.
expect(maxHeightMatch![1].trim()).toBe(minHeightMatch![1].trim());
// Centered, tokenized line-height so text-only, icon-only, and text+icon
// content (Save's inline <Save size={12}> icon) all resolve within the
// same fixed box regardless of natural content height.
expect(baseRuleBody).toMatch(/line-height:\s*var\(--line-height-tight\)/);
expect(baseRuleBody).toMatch(/align-items:\s*center/);
});
it("declares the SAME fixed-height contract (min-height == max-height) inside the ≤768px touch-target media block, not only at desktop widths", () => {
const cssContent = loadAllAppCss();
// Anchor on the unique "Quick Entry Mobile Touch + Overflow Fixes" marker
// comment (rather than a generic "@media (max-width: 768px)" boundary,
// which recurs across other concatenated component/style CSS files) to
// isolate this specific mobile touch-target block unambiguously.
const mobileRuleMatch = cssContent.match(
/Quick Entry Mobile Touch[\s\S]*?\.quick-entry-actions \.btn,\s*\n\s*\.quick-entry-actions \.wf-optional-steps-dropdown-trigger \{([^}]*)\}/,
);
expect(mobileRuleMatch).not.toBeNull();
const mobileRuleBody = mobileRuleMatch![1];
const minHeightMatch = mobileRuleBody.match(/min-height:\s*([^;]+);/);
const maxHeightMatch = mobileRuleBody.match(/max-height:\s*([^;]+);/);
expect(minHeightMatch).not.toBeNull();
expect(maxHeightMatch).not.toBeNull();
expect(maxHeightMatch![1].trim()).toBe(minHeightMatch![1].trim());
expect(mobileRuleBody).toMatch(/line-height:\s*var\(--line-height-tight\)/);
expect(mobileRuleBody).toMatch(/align-items:\s*center/);
// Mobile and desktop intentionally use different fixed heights (36px vs
// 28px touch targets) — that is expected; parity is required WITHIN each
// breakpoint, not across breakpoints.
expect(minHeightMatch![1].trim()).toBe("calc(var(--space-2xl) + var(--space-xs))");
});
it("does not modify the shared global .btn-sm / .btn-icon / .dep-trigger rules in styles.css (cross-surface regression guard)", () => {
const stylesCssContent = loadStylesCss();
const btnSmMatch = stylesCssContent.match(/\.btn-sm\s*\{([^}]*)\}/);
expect(btnSmMatch).not.toBeNull();
expect(btnSmMatch![1]).not.toMatch(/max-height/);
const btnIconMatch = stylesCssContent.match(/\.btn-icon\s*\{([^}]*)\}/);
expect(btnIconMatch).not.toBeNull();
expect(btnIconMatch![1]).toMatch(/line-height:\s*0/);
const depTriggerMatches = stylesCssContent.match(/\.dep-trigger,\s*\n\s*\.inline-create-model-trigger\s*\{/g);
expect(depTriggerMatches).not.toBeNull();
expect(depTriggerMatches!.length).toBe(1);
});
it("renders Save, Attach, GitHub, Priority, Fast, and the workflow trigger as siblings in one .quick-entry-actions row (no shell/layout regression; jsdom cannot measure real pixel heights)", () => {
mockDesktopViewport();
renderQuickEntryBox();
const saveButton = screen.getByTestId("quick-entry-save");
const attachButton = screen.getByTestId("quick-entry-attach");
const githubToggle = screen.getByTestId("quick-entry-github-toggle");
const priorityButton = screen.getByTestId("quick-entry-priority-button");
const fastToggle = screen.getByTestId("quick-entry-fast-toggle");
const workflowTrigger = screen.getByTestId("quick-entry-workflow-trigger");
const actionsRow = saveButton.closest(".quick-entry-actions");
expect(actionsRow).not.toBeNull();
expect(actionsRow?.contains(attachButton)).toBe(true);
expect(actionsRow?.contains(githubToggle)).toBe(true);
expect(actionsRow?.contains(priorityButton)).toBe(true);
expect(actionsRow?.contains(fastToggle)).toBe(true);
expect(actionsRow?.contains(workflowTrigger)).toBe(true);
});
it("applies a Save-only mobile correction (tighter line-height, no vertical padding) scoped to the ≤768px breakpoint, without touching the shared desktop rule", () => {
const cssContent = loadAllAppCss();
// The mobile-only Save override must exist, targeting the existing
// quick-entry-save test id (no new wrapper element), inside a
// max-width: 768px media query.
const mobileSaveRuleMatch = cssContent.match(
/@media \(max-width: 768px\) \{[\s\S]*?\.quick-entry-actions \[data-testid="quick-entry-save"\] \{([^}]*)\}/,
);
expect(mobileSaveRuleMatch).not.toBeNull();
const mobileSaveRuleBody = mobileSaveRuleMatch![1];
expect(mobileSaveRuleBody).toMatch(/padding-top:\s*0/);
expect(mobileSaveRuleBody).toMatch(/padding-bottom:\s*0/);
expect(mobileSaveRuleBody).toMatch(/line-height:\s*1\b/);
// The desktop base (non-media) rule must NOT contain this Save-specific
// override — desktop/tablet Save sizing is intentionally left untouched.
const withoutMediaBlocks = cssContent.replace(/@media[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, "");
expect(withoutMediaBlocks).not.toMatch(/\[data-testid="quick-entry-save"\]/);
// The shared `.quick-entry-actions .btn, .quick-entry-actions
// .wf-optional-steps-dropdown-trigger` fixed-height contract (governing
// every sibling control at both breakpoints) must remain exactly as
// FN-7683 left it — this follow-up must not weaken or duplicate it.
const sharedRuleMatches = cssContent.match(
/\.quick-entry-actions \.btn,\s*\n\.quick-entry-actions \.wf-optional-steps-dropdown-trigger \{/g,
);
expect(sharedRuleMatches).not.toBeNull();
expect(sharedRuleMatches!.length).toBe(1);
});
});