## The defect The enrich-mode task picker filtered with `task.column !== "archived"`. On a board whose archive lane is renamed, that matched nothing — so filed-away tasks stayed in the picker and an operator could attach research findings to work they had deliberately archived. ## Census before / after | | before | after | |---|---|---| | COLUMN guards (backlog) | 10 | **9** | | `ResearchTaskActionModal.tsx` | 1 | **0 — converted** | Baseline re-recorded in the same commit; `--strict` green. ## This site was declined twice, and I wrote the second wrong estimate #3213 left it counted, correctly, on the note that was here — which was mine. Both prior cost estimates were wrong, so this corrects my own work: 1. **"Needs a data-fetch change"** — reasoned about `columnFlagsByTaskId`, a per-**task** map built from board-resident rows. Right that such a map can't help (archived rows are exactly what a board map omits), but this guard asks a per-**column** question, so it never needed one. 2. **"Needs prop threading, MainContent → ResearchView → here"** — right that the answer is column-keyed, wrong about where it lives. `ListView` builds `columnFlagsById` *inline*, which made it look like the owner. The data is `useBoardWorkflows`, a hook already called from `App`, `Board`, and `HeaderWorkflowSwitcherSlot`. **Measured cost: one file.** The modal already takes `projectId`, and `ResearchView` renders it only when a finding is open (`open` hardcoded beside `if (!finding) return null`) — so the hook cannot fetch for a closed modal, which was the one real objection to calling it here. Union across workflows keyed by column id, first declaration wins — the same convention `ListView` uses, so the two cannot disagree about a shared id. `isArchivedColumnRole` fail-softs to the legacy id when a column has no flags, so an unresolved workflow behaves exactly as the literal did. ## Tests — the invariant, not the repro Per the surface-enumeration rule, four cases: renamed archive lane, legacy id, unresolved workflow (fail-soft), and a second workflow's archive lane through the cross-workflow union. A repro-only test would pass on the legacy board and prove nothing about the case the guard exists for. **Anti-vacuity control:** | | renamed lane | union | legacy id | fail-soft | |---|---|---|---|---| | pre-fix literal | **FAIL** | **FAIL** | pass | pass | | converted | pass | pass | pass | pass | The legacy and fail-soft cases hold in both directions **on purpose** — they pin that this conversion did not change the pre-resolution answer. Flagging that so 4/4 isn't read as four independent proofs. ## Measured | check | result | |---|---| | `census --strict` / `check-fnxc-future-dates` | exit 0 / exit 0 | | `eslint` | clean | | `tsc -p tsconfig.app.json` (the config that actually covers `app/`) | exit 0 | | new tests | 4/4 | | `pnpm test:gate` | exit 0 (744 tests) | ## Note on process My first attempt at the control silently did nothing — the revert script threw a `SyntaxError`, so the "pre-fix" run was the fixed code and reported 4/4. Caught it because the error printed. The table above is from the re-run.
180 lines
9.2 KiB
TypeScript
180 lines
9.2 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import type { Task, TaskPriority } from "@fusion/core";
|
||
import { fetchTasks } from "../api";
|
||
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||
import { isArchivedColumnRole } from "../utils/columnRoles";
|
||
import type { ResearchRunDetail } from "../research-types";
|
||
import "./ResearchTaskActionModal.css";
|
||
|
||
type Mode = "create" | "enrich";
|
||
|
||
interface ResearchTaskActionModalProps {
|
||
open: boolean;
|
||
mode: Mode;
|
||
run: ResearchRunDetail;
|
||
finding: { id: string; heading?: string; content?: string };
|
||
projectId?: string;
|
||
onClose: () => void;
|
||
onConfirm: (payload: { taskId?: string; title?: string; description?: string; priority?: TaskPriority; attachExport: boolean }) => Promise<void>;
|
||
}
|
||
|
||
export function ResearchTaskActionModal({ open, mode, run, finding, projectId, onClose, onConfirm }: ResearchTaskActionModalProps) {
|
||
const { t } = useTranslation("app");
|
||
useMobileScrollLock(open);
|
||
const [attachExport, setAttachExport] = useState(false);
|
||
const [title, setTitle] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [priority, setPriority] = useState<TaskPriority>("normal");
|
||
const [taskId, setTaskId] = useState("");
|
||
const [tasks, setTasks] = useState<Task[]>([]);
|
||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
/*
|
||
FNXC:WorkflowResolvedColumns 2026-07-31-11:40 (u12 — CONVERTED, and the earlier cost estimate was wrong):
|
||
The note this replaces said converting the filter below needed prop threading through three
|
||
components (MainContent -> ResearchView -> here) because `ListView.tsx:756` builds `columnFlagsById`
|
||
locally. That was wrong about WHERE the data lives: `listColumns` derives from `useBoardWorkflows`,
|
||
a hook already called from App, Board and HeaderWorkflowSwitcherSlot. ListView only looked like the
|
||
owner because it happens to build the map inline.
|
||
|
||
So the real cost is this file, and nothing else. The modal already takes `projectId`, and
|
||
ResearchView renders it ONLY when a finding is open (`open` is hardcoded true beside a
|
||
`if (!finding) return null`), so the hook cannot fetch for a closed modal.
|
||
|
||
Union across workflows keyed by column id, first declaration wins — the same convention
|
||
`ListView.tsx` uses for its cross-workflow map, so the two cannot disagree about a shared id.
|
||
*/
|
||
const { boardWorkflows } = useBoardWorkflows({ projectId });
|
||
const isArchivedColumn = useMemo(() => {
|
||
const flagsById = new Map<string, { archived?: boolean }>();
|
||
for (const workflow of boardWorkflows?.workflows ?? []) {
|
||
for (const column of workflow.columns) {
|
||
if (!flagsById.has(column.id)) flagsById.set(column.id, column.flags);
|
||
}
|
||
}
|
||
/* `isArchivedColumnRole` fail-softs to the legacy id when a column has no flags, which is the
|
||
pre-resolution answer — so an unresolved workflow behaves exactly as this filter did before. */
|
||
return (column: string): boolean => isArchivedColumnRole(flagsById.get(column), column);
|
||
}, [boardWorkflows]);
|
||
|
||
const preview = useMemo(() => {
|
||
const firstSentence = (finding.content ?? "").split(/(?<=[.!?])\s+/)[0] ?? "";
|
||
return `${finding.heading || t("research.defaultFindingHeading", "Research finding")} — ${firstSentence}`.trim();
|
||
}, [finding.content, finding.heading, t]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setAttachExport(false);
|
||
setTitle(`Research: ${finding.heading || run.title}`);
|
||
setDescription(preview);
|
||
setPriority("normal");
|
||
setTaskId("");
|
||
|
||
if (mode === "enrich") {
|
||
setLoadingTasks(true);
|
||
void fetchTasks(50, 0, projectId)
|
||
/*
|
||
FNXC:WorkflowResolvedColumns 2026-07-31-11:45 (u12 — the history, kept short because it is CONVERTED now):
|
||
This guard was sized twice and declined twice, each time on a cost that turned out to be wrong.
|
||
Recorded because both wrong answers are instructive, not to relitigate them:
|
||
|
||
1. "Needs a data-fetch change" — reasoning about `columnFlagsByTaskId`, a per-TASK map built
|
||
from board-resident rows. Correct that such a map cannot help (archived rows are exactly
|
||
what a board map omits), but this guard asks a per-COLUMN question, so it never needed one.
|
||
2. "Needs prop threading, MainContent -> ResearchView -> here" — correct that the answer is
|
||
column-keyed, wrong about where it lives. `ListView` builds `columnFlagsById` inline, which
|
||
made it look like the owner; the data is `useBoardWorkflows`, callable from here directly.
|
||
|
||
The guard was real either way: on a renamed board `archived` matched nothing, so filed-away
|
||
tasks stayed in this picker and an operator could attach findings to work they had archived.
|
||
*/
|
||
.then((rows) => setTasks(rows.filter((task) => !isArchivedColumn(task.column))))
|
||
.finally(() => setLoadingTasks(false));
|
||
}
|
||
}, [open, mode, projectId, finding.heading, preview, run.title, isArchivedColumn]);
|
||
|
||
if (!open) return null;
|
||
|
||
return (
|
||
<div className="modal-overlay open" role="presentation" onClick={onClose}>
|
||
<div className="modal modal-lg research-task-action-modal" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>{mode === "create" ? t("research.createTaskTitle", "Create task from finding") : t("research.enrichTaskTitle", "Enrich existing task")}</h3>
|
||
<button className="modal-close" type="button" aria-label={t("actions.close", "Close")} onClick={onClose}>×</button>
|
||
</div>
|
||
|
||
<div className="research-task-action-modal__body">
|
||
<div className="card research-task-action-modal__preview">
|
||
<p><strong>{t("research.runLabel", "Run:")} </strong> {run.id}</p>
|
||
<p><strong>{t("research.findingLabel", "Finding:")} </strong> {finding.id}{finding.heading ? ` — ${finding.heading}` : ""}</p>
|
||
<p>{preview || t("research.noPreview", "No preview available.")}</p>
|
||
</div>
|
||
|
||
{mode === "create" ? (
|
||
<>
|
||
<label className="research-task-action-modal__field">{t("research.titleLabel", "Title")}
|
||
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||
</label>
|
||
<label className="research-task-action-modal__field">{t("research.descriptionLabel", "Description")}
|
||
<textarea className="input research-task-action-modal__textarea" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||
</label>
|
||
<label className="research-task-action-modal__field">{t("research.priorityLabel", "Priority")}
|
||
<select className="select" value={priority} onChange={(event) => setPriority(event.target.value as TaskPriority)}>
|
||
<option value="low">{t("research.priorityLow", "Low")}</option>
|
||
<option value="normal">{t("research.priorityNormal", "Normal")}</option>
|
||
<option value="high">{t("research.priorityHigh", "High")}</option>
|
||
<option value="urgent">{t("research.priorityUrgent", "Urgent")}</option>
|
||
</select>
|
||
</label>
|
||
</>
|
||
) : (
|
||
<label className="research-task-action-modal__field">{t("research.targetTaskLabel", "Target task")}
|
||
<input
|
||
className="input"
|
||
list="research-task-action-task-list"
|
||
value={taskId}
|
||
placeholder={loadingTasks ? t("research.loadingTasks", "Loading tasks…") : t("research.enterTaskId", "Enter task ID")}
|
||
onChange={(event) => setTaskId(event.target.value)}
|
||
/>
|
||
<datalist id="research-task-action-task-list">
|
||
{tasks.map((task) => (
|
||
<option key={task.id} value={task.id}>{task.title}</option>
|
||
))}
|
||
</datalist>
|
||
</label>
|
||
)}
|
||
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" checked={attachExport} onChange={(event) => setAttachExport(event.target.checked)} />
|
||
<span>{t("research.attachExport", "Attach markdown export artifact")}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="modal-actions">
|
||
<button className="btn" type="button" onClick={onClose}>{t("actions.cancel", "Cancel")}</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
type="button"
|
||
disabled={saving || (mode === "enrich" && !taskId)}
|
||
onClick={() => {
|
||
setSaving(true);
|
||
void onConfirm({
|
||
taskId: mode === "enrich" ? taskId : undefined,
|
||
title: mode === "create" ? title.trim() : undefined,
|
||
description: mode === "create" ? description.trim() : undefined,
|
||
priority: mode === "create" ? priority : undefined,
|
||
attachExport,
|
||
}).finally(() => setSaving(false));
|
||
}}
|
||
>
|
||
{mode === "create" ? t("research.createTaskButton", "Create Task") : t("research.enrichTaskButton", "Enrich Task")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|