feat(FN-3183): add inline priority picker to quick-entry box
Adds an inline priority picker to the Quick Entry Box and Task Detail Modal with shared badge styling and proper mobile tap targets (FN-3183, FN-3186), updates agent tools tests and documentation for layered memory access patterns (FN-3179), and fixes styling alignment between the modal and inline c Fusion-Task-Id: FN-3183
This commit is contained in:
@@ -156,36 +156,46 @@
|
||||
}
|
||||
|
||||
.detail-priority-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
--detail-priority-control-min-height: calc(var(--space-2xl) + var(--space-xs));
|
||||
|
||||
gap: var(--space-xs);
|
||||
margin-left: var(--space-xs);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
text-transform: capitalize;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
min-height: var(--detail-priority-control-min-height);
|
||||
}
|
||||
|
||||
.detail-priority-chip--low {
|
||||
background: color-mix(in srgb, var(--color-info) 14%, transparent);
|
||||
color: var(--color-info);
|
||||
.detail-priority-chip--saving {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.detail-priority-chip--normal {
|
||||
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
color: var(--text-muted);
|
||||
.detail-priority-select {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
min-height: inherit;
|
||||
}
|
||||
|
||||
.detail-priority-chip--high {
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
|
||||
color: var(--color-warning);
|
||||
.detail-priority-select:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.detail-priority-chip--urgent {
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
color: var(--color-error-dark);
|
||||
.detail-priority-select:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.detail-priority-select option {
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
/* Error alert in task detail modal */
|
||||
.detail-error-alert {
|
||||
display: flex;
|
||||
@@ -1375,6 +1385,15 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-priority-chip {
|
||||
margin-left: 0;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.detail-priority-select {
|
||||
min-height: var(--detail-priority-control-min-height);
|
||||
}
|
||||
|
||||
.detail-source-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -429,6 +429,8 @@ export function TaskDetailModal({
|
||||
const [editSourceIssueUrl, setEditSourceIssueUrl] = useState(task.sourceIssue?.url ?? "");
|
||||
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [inlinePriority, setInlinePriority] = useState<TaskPriority>(normalizeTaskPriorityValue(task.priority));
|
||||
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
// Split-menu dropdown state for footer actions
|
||||
@@ -487,6 +489,10 @@ export function TaskDetailModal({
|
||||
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
|
||||
}, [task.id, task.enabledWorkflowSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
setInlinePriority(normalizeTaskPriorityValue(task.priority));
|
||||
}, [task.id, task.priority]);
|
||||
|
||||
// Load merged settings for effective model resolution
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -862,6 +868,34 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [task.id, addToast, projectId, onTaskUpdated]);
|
||||
|
||||
const handleInlinePriorityChange = useCallback(async (nextValue: string) => {
|
||||
const normalizedNextPriority = normalizeTaskPriorityValue(nextValue as Task["priority"]);
|
||||
const currentPriority = normalizeTaskPriorityValue(task.priority);
|
||||
|
||||
if (normalizedNextPriority === currentPriority) {
|
||||
setInlinePriority(currentPriority);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousPriority = inlinePriority;
|
||||
setInlinePriority(normalizedNextPriority);
|
||||
setIsSavingInlinePriority(true);
|
||||
|
||||
try {
|
||||
const updatedTask = await updateTask(task.id, { priority: normalizedNextPriority }, projectId);
|
||||
setInlinePriority(normalizeTaskPriorityValue(updatedTask.priority));
|
||||
onTaskUpdated?.(updatedTask);
|
||||
addToast(`Priority updated to ${normalizeTaskPriorityValue(updatedTask.priority)}`, "success");
|
||||
} catch (err) {
|
||||
setInlinePriority(previousPriority);
|
||||
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setIsSavingInlinePriority(false);
|
||||
}
|
||||
}
|
||||
}, [task.id, task.priority, projectId, inlinePriority, onTaskUpdated, addToast]);
|
||||
|
||||
// Handle keyboard shortcuts for edit mode
|
||||
const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!isEditing) return;
|
||||
@@ -1608,9 +1642,26 @@ export function TaskDetailModal({
|
||||
<div className="detail-meta">
|
||||
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
||||
{new Date(task.updatedAt).toLocaleDateString()} ·
|
||||
<span className={`detail-priority-chip detail-priority-chip--${normalizeTaskPriorityValue(task.priority)}`}>
|
||||
Priority: {normalizeTaskPriorityValue(task.priority)}
|
||||
</span>
|
||||
<label
|
||||
className={`card-priority-badge card-priority-badge--${inlinePriority} detail-priority-chip ${isSavingInlinePriority ? "detail-priority-chip--saving" : ""}`}
|
||||
>
|
||||
<span>Priority:</span>
|
||||
<select
|
||||
className="detail-priority-select"
|
||||
value={inlinePriority}
|
||||
onChange={(event) => {
|
||||
void handleInlinePriorityChange(event.target.value);
|
||||
}}
|
||||
disabled={isSavingInlinePriority}
|
||||
aria-label="Task priority"
|
||||
>
|
||||
{TASK_PRIORITIES.map((priorityOption) => (
|
||||
<option key={priorityOption} value={priorityOption}>
|
||||
{priorityOption}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{provenanceDisplay && (
|
||||
<div className="detail-provenance">
|
||||
<GitBranch aria-hidden="true" />
|
||||
|
||||
@@ -5141,7 +5141,7 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders normalized priority in detail metadata", () => {
|
||||
it("renders normalized priority in detail metadata", async () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: undefined })}
|
||||
@@ -5154,7 +5154,95 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Priority: normal")).toBeTruthy();
|
||||
const prioritySelect = screen.getByRole("combobox", { name: "Task priority" }) as HTMLSelectElement;
|
||||
expect(prioritySelect.value).toBe("normal");
|
||||
});
|
||||
|
||||
it("updates priority inline and propagates successful save", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
const onTaskUpdated = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const updatedTask = makeTask({ id: "FN-001", column: "triage", priority: "urgent" });
|
||||
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: "normal" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Task priority" }), {
|
||||
target: { value: "urgent" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
|
||||
});
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
||||
expect(addToast).toHaveBeenCalledWith("Priority updated to urgent", "success");
|
||||
});
|
||||
|
||||
it("does not call updateTask when inline priority is unchanged", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: "high" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Task priority" }), {
|
||||
target: { value: "high" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("reverts inline priority when save fails", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
const addToast = vi.fn();
|
||||
mockUpdate.mockRejectedValueOnce(new Error("Request failed"));
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: "low" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
const prioritySelect = screen.getByRole("combobox", { name: "Task priority" }) as HTMLSelectElement;
|
||||
fireEvent.change(prioritySelect, { target: { value: "urgent" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(prioritySelect.value).toBe("low");
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error");
|
||||
});
|
||||
|
||||
it("pre-populates form with existing task values", () => {
|
||||
|
||||
Reference in New Issue
Block a user