feat(dashboard,cli): U13 — schema-driven task fields UI (TaskFieldsSection, card badges, PATCH route, board-workflows fields payload, TUI chips)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1613,6 +1613,15 @@ function TaskDetailScreen({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Card-placed custom fields (U13/KTD-14): read-only bracketed labels. */}
|
||||
{detail.customFields && detail.customFields.length > 0 && (
|
||||
<Box flexDirection="row" gap={1} flexWrap="wrap" flexShrink={0}>
|
||||
{detail.customFields.map((f) => (
|
||||
<Text key={f.label} color="magenta">[{f.label}: {f.value}]</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box height={1} flexShrink={0} />
|
||||
|
||||
{/* Steps section */}
|
||||
|
||||
@@ -230,6 +230,10 @@ export interface TaskDetailData {
|
||||
currentStepIndex?: number;
|
||||
steps: TaskStep[];
|
||||
recentLogs: TaskLogEntry[]; // last ~200 entries on initial load
|
||||
/** Card-placed custom field values, pre-rendered as read-only bracketed
|
||||
* labels for the task detail view (U13/KTD-14). Absent/empty when the
|
||||
* workflow declares no card fields or none have values. */
|
||||
customFields?: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
export type TaskEvent =
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
isWorkflowColumnsEnabled,
|
||||
resolveColumnFlags,
|
||||
BUILTIN_CODING_WORKFLOW_IR,
|
||||
parseWorkflowIr,
|
||||
type WorkflowIrColumn,
|
||||
type TraitFlags,
|
||||
} from "@fusion/core";
|
||||
@@ -2742,6 +2743,48 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
text: entry.outcome ? `${entry.action} → ${entry.outcome}` : entry.action,
|
||||
source: entry.runContext?.agentId ? "agent" : "executor",
|
||||
}));
|
||||
// Card-placed custom fields → read-only bracketed labels
|
||||
// (U13/KTD-14). Resolve the task's workflow IR, filter
|
||||
// card-placed field defs, and render any present values.
|
||||
// Best-effort: any resolution failure simply omits the chips.
|
||||
let customFields: Array<{ label: string; value: string }> | undefined;
|
||||
try {
|
||||
const values = (t as { customFields?: Record<string, unknown> }).customFields;
|
||||
if (values && Object.keys(values).length > 0) {
|
||||
const selection = projectStore.getTaskWorkflowSelection(t.id);
|
||||
const def = selection?.workflowId
|
||||
? await projectStore.getWorkflowDefinition(selection.workflowId)
|
||||
: undefined;
|
||||
const ir = def
|
||||
? (typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir)
|
||||
: BUILTIN_CODING_WORKFLOW_IR;
|
||||
const fields = ir.version === "v2" ? (ir.fields ?? []) : [];
|
||||
const chips: Array<{ label: string; value: string }> = [];
|
||||
for (const field of fields) {
|
||||
if (field.render?.placement !== "card") continue;
|
||||
const raw = values[field.id];
|
||||
if (raw === undefined || raw === null || raw === "") continue;
|
||||
const optLabel = (v: string): string =>
|
||||
field.options?.find((o) => o.value === v)?.label ?? v;
|
||||
let display: string;
|
||||
if (field.type === "boolean") {
|
||||
if (raw !== true) continue;
|
||||
display = field.name;
|
||||
} else if (field.type === "multi-enum" && Array.isArray(raw)) {
|
||||
if (raw.length === 0) continue;
|
||||
display = raw.map((v) => optLabel(String(v))).join(", ");
|
||||
} else if (field.type === "enum") {
|
||||
display = optLabel(String(raw));
|
||||
} else {
|
||||
display = String(raw);
|
||||
}
|
||||
chips.push({ label: field.name, value: display });
|
||||
}
|
||||
if (chips.length > 0) customFields = chips;
|
||||
}
|
||||
} catch {
|
||||
customFields = undefined;
|
||||
}
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
@@ -2753,6 +2796,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
currentStepIndex: t.currentStep,
|
||||
steps,
|
||||
recentLogs,
|
||||
...(customFields ? { customFields } : {}),
|
||||
};
|
||||
} catch {
|
||||
// Task not found (deleted/archived between selection and fetch).
|
||||
|
||||
@@ -552,10 +552,52 @@ export interface BoardWorkflowColumn {
|
||||
flags: BoardWorkflowColumnFlags;
|
||||
}
|
||||
|
||||
/** Supported custom-field value types (mirrors core `WorkflowFieldType`, KTD-13).
|
||||
* Duplicated client-side (same posture as the BoardWorkflow* types above) since
|
||||
* the core field-schema types are not exported through the `@fusion/core`
|
||||
* barrel. */
|
||||
export type WorkflowFieldType =
|
||||
| "string"
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "enum"
|
||||
| "multi-enum"
|
||||
| "date"
|
||||
| "url";
|
||||
|
||||
/** A single enum/multi-enum option (KTD-13). */
|
||||
export interface WorkflowFieldOption {
|
||||
value: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Rendering instructions for a custom field (KTD-14). */
|
||||
export interface WorkflowFieldRender {
|
||||
placement?: "card" | "detail" | "detail-section";
|
||||
widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle";
|
||||
badge?: boolean;
|
||||
}
|
||||
|
||||
/** A workflow-defined custom task field (KTD-13). */
|
||||
export interface WorkflowFieldDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
type: WorkflowFieldType;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
options?: WorkflowFieldOption[];
|
||||
render?: WorkflowFieldRender;
|
||||
}
|
||||
|
||||
export interface BoardWorkflowDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
columns: BoardWorkflowColumn[];
|
||||
/** Custom field definitions declared by this workflow (U13/KTD-14). Absent on
|
||||
* workflows with no fields, or from older servers. */
|
||||
fields?: WorkflowFieldDefinition[];
|
||||
}
|
||||
|
||||
export interface BoardWorkflowsPayload {
|
||||
@@ -565,6 +607,31 @@ export interface BoardWorkflowsPayload {
|
||||
taskWorkflowIds: Record<string, string>;
|
||||
}
|
||||
|
||||
/** A typed custom-field rejection surfaced by the PATCH endpoint (KTD-13). */
|
||||
export interface CustomFieldRejection {
|
||||
code: "no-fields-defined" | "unknown-field" | "type-mismatch" | "enum-violation";
|
||||
fieldId: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a task's custom field values (U13/KTD-14). The server validates the
|
||||
* patch against the task's workflow field schema and returns the updated task;
|
||||
* a validation failure surfaces as a 400 carrying `{ fieldId, code, detail }`.
|
||||
* A `null` value for a field deletes it.
|
||||
*/
|
||||
export function updateTaskCustomFields(
|
||||
id: string,
|
||||
customFields: Record<string, unknown>,
|
||||
projectId?: string,
|
||||
): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/custom-fields`, projectId), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customFields }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch the multi-lane board metadata (U9). When the flag is OFF the server
|
||||
* returns `{ flagEnabled: false }` and the board renders its legacy form. */
|
||||
export function fetchBoardWorkflows(projectId?: string): Promise<BoardWorkflowsPayload> {
|
||||
|
||||
@@ -379,6 +379,28 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
return result;
|
||||
}, [boardWorkflows, flagOn, tasks]);
|
||||
|
||||
// Card-placed custom field definitions per task (U13/KTD-14). Resolves each
|
||||
// task's workflow from the board-workflows payload and exposes that workflow's
|
||||
// card-placed field defs so TaskCard can render value badges. Empty map when
|
||||
// no workflow declares card fields — cards stay byte-identical.
|
||||
const taskCardFieldDefs = useMemo(() => {
|
||||
const map = new Map<string, import("../api").WorkflowFieldDefinition[]>();
|
||||
if (!boardWorkflows) return map;
|
||||
const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows;
|
||||
const cardDefsByWorkflow = new Map<string, import("../api").WorkflowFieldDefinition[]>();
|
||||
for (const wf of workflows) {
|
||||
const cardDefs = (wf.fields ?? []).filter((f) => f.render?.placement === "card");
|
||||
if (cardDefs.length > 0) cardDefsByWorkflow.set(wf.id, cardDefs);
|
||||
}
|
||||
if (cardDefsByWorkflow.size === 0) return map;
|
||||
for (const task of tasks) {
|
||||
const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId;
|
||||
const defs = cardDefsByWorkflow.get(workflowId);
|
||||
if (defs) map.set(task.id, defs);
|
||||
}
|
||||
return map;
|
||||
}, [boardWorkflows, tasks]);
|
||||
|
||||
// Drag pre-check (R17): adjacency + capacity from the lane's column metadata.
|
||||
// Cross-lane drag → workflow-mismatch. Deterministic rejections return a
|
||||
// messageKey (no-move); null = allowed.
|
||||
@@ -467,6 +489,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
/>
|
||||
@@ -508,6 +531,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMerge={autoMerge}
|
||||
|
||||
@@ -140,6 +140,8 @@ interface ColumnProps {
|
||||
lastFetchTimeMs?: number;
|
||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Per-task card-placed custom field definitions (U13/KTD-14). */
|
||||
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
|
||||
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
|
||||
@@ -168,7 +170,7 @@ interface ColumnProps {
|
||||
getDraggingTaskId?: () => string | null;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap, prAuthAvailable, workflowMode, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
||||
const { t } = useTranslation("app");
|
||||
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
||||
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
||||
@@ -695,6 +697,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={Boolean(autoMerge)}
|
||||
@@ -725,6 +728,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onMoveTask={onMoveTask}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={Boolean(autoMerge)}
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface LaneProps {
|
||||
onOpenMission?: (missionId: string) => void;
|
||||
lastFetchTimeMs?: number;
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Per-task card-placed custom field definitions (U13/KTD-14). */
|
||||
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
|
||||
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||
prAuthAvailable?: boolean;
|
||||
}
|
||||
@@ -191,6 +193,7 @@ function LaneComponent(props: LaneProps) {
|
||||
onOpenMission={props.onOpenMission}
|
||||
lastFetchTimeMs={props.lastFetchTimeMs}
|
||||
workflowStepNameLookup={props.workflowStepNameLookup}
|
||||
taskCardFieldDefs={props.taskCardFieldDefs}
|
||||
blockerFanoutMap={props.blockerFanoutMap}
|
||||
prAuthAvailable={props.prAuthAvailable}
|
||||
autoMerge={props.autoMerge}
|
||||
|
||||
@@ -1447,3 +1447,53 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card-placed custom field badges (U13 / KTD-14). */
|
||||
.card-field-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
|
||||
.card-field-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 7px;
|
||||
border: 1px solid var(--border-color, #2a2d34);
|
||||
border-radius: 999px;
|
||||
background: var(--chip-bg, #1c1f26);
|
||||
color: var(--text-secondary, #b4b8c0);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
max-width: 16ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-field-badge--boolean {
|
||||
background: var(--accent, #4f7cff);
|
||||
border-color: var(--accent, #4f7cff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-field-badge--multi {
|
||||
gap: 3px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.card-field-badge-token {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-color, #2a2d34);
|
||||
background: var(--chip-bg, #1c1f26);
|
||||
}
|
||||
|
||||
.card-field-badge--overflow {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./TaskCard.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo, type ReactElement } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core";
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
VALID_TRANSITIONS,
|
||||
getErrorMessage,
|
||||
} from "@fusion/core";
|
||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, type WorkflowFieldDefinition } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
import { PrCreateModal } from "./PrCreateModal";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -299,6 +299,72 @@ export function formatElapsedDurationDone(elapsedMs: number): string {
|
||||
}
|
||||
|
||||
|
||||
/** Max number of card-placed custom fields rendered before an overflow chip
|
||||
* (KTD-14: "max 3 card fields rendered with a +N overflow indicator"). */
|
||||
const MAX_CARD_FIELDS = 3;
|
||||
|
||||
/** Render a single card-placed custom field value as a badge/chip (U13/KTD-14).
|
||||
* Returns null for empty/unset values so absent fields take no card space. */
|
||||
function renderCardFieldBadge(
|
||||
field: WorkflowFieldDefinition,
|
||||
value: unknown,
|
||||
): ReactElement | null {
|
||||
const colorOf = (v: string): string | undefined => field.options?.find((o) => o.value === v)?.color;
|
||||
const labelOf = (v: string): string => field.options?.find((o) => o.value === v)?.label ?? v;
|
||||
|
||||
if (field.type === "boolean") {
|
||||
// Boolean true → labeled chip; false/unset → nothing.
|
||||
if (value !== true) return null;
|
||||
return (
|
||||
<span key={field.id} className="card-field-badge card-field-badge--boolean" title={field.name}>
|
||||
{field.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (field.type === "enum") {
|
||||
if (typeof value !== "string" || value === "") return null;
|
||||
const color = colorOf(value);
|
||||
return (
|
||||
<span
|
||||
key={field.id}
|
||||
className="card-field-badge card-field-badge--enum"
|
||||
title={`${field.name}: ${labelOf(value)}`}
|
||||
style={color ? { backgroundColor: color, borderColor: color, color: "#fff" } : undefined}
|
||||
>
|
||||
{labelOf(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (field.type === "multi-enum") {
|
||||
const arr = Array.isArray(value) ? (value as string[]) : [];
|
||||
if (arr.length === 0) return null;
|
||||
return (
|
||||
<span key={field.id} className="card-field-badge card-field-badge--multi" title={field.name}>
|
||||
{arr.map((v) => {
|
||||
const color = colorOf(v);
|
||||
return (
|
||||
<span
|
||||
key={v}
|
||||
className="card-field-badge-token"
|
||||
style={color ? { backgroundColor: color, borderColor: color, color: "#fff" } : undefined}
|
||||
>
|
||||
{labelOf(v)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// string / text / number / date / url → simple labeled chip.
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const display = field.type === "date" && typeof value === "string" ? value.slice(0, 10) : String(value);
|
||||
return (
|
||||
<span key={field.id} className="card-field-badge" title={`${field.name}: ${display}`}>
|
||||
{display}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
projectId?: string;
|
||||
@@ -338,6 +404,9 @@ interface TaskCardProps {
|
||||
prAuthAvailable?: boolean;
|
||||
/** Whether project-level auto-merge is enabled (hides manual Create PR quick action when true). */
|
||||
autoMergeEnabled?: boolean;
|
||||
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
|
||||
* Empty/undefined → no field badges render (card byte-identical to today). */
|
||||
cardFieldDefs?: WorkflowFieldDefinition[];
|
||||
}
|
||||
|
||||
function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
|
||||
@@ -471,6 +540,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
|
||||
previous.prAuthAvailable === next.prAuthAvailable &&
|
||||
previous.autoMergeEnabled === next.autoMergeEnabled &&
|
||||
previous.cardFieldDefs === next.cardFieldDefs &&
|
||||
JSON.stringify(previousTask.customFields ?? null) === JSON.stringify(nextTask.customFields ?? null) &&
|
||||
previous.onOpenDetail === next.onOpenDetail &&
|
||||
previous.onOpenGroupModal === next.onOpenGroupModal &&
|
||||
previous.addToast === next.addToast &&
|
||||
@@ -584,6 +655,7 @@ function TaskCardComponent({
|
||||
fanout,
|
||||
prAuthAvailable,
|
||||
autoMergeEnabled = false,
|
||||
cardFieldDefs,
|
||||
}: TaskCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const columnLabel = useColumnLabel();
|
||||
@@ -1947,6 +2019,30 @@ function TaskCardComponent({
|
||||
<div className="card-title" title={task.title || task.description || undefined}>
|
||||
{truncate(task.title, MAX_TITLE_LENGTH) || truncate(task.description, MAX_TITLE_LENGTH) || task.id}
|
||||
</div>
|
||||
{(() => {
|
||||
// Card-placed custom field badges (U13/KTD-14). Bounded to MAX_CARD_FIELDS
|
||||
// with a "+N" overflow chip. Nothing renders when no card fields are
|
||||
// defined or all values are empty — card stays byte-identical to today.
|
||||
const cardDefs = (cardFieldDefs ?? []).filter((f) => f.render?.placement === "card");
|
||||
if (cardDefs.length === 0) return null;
|
||||
const values = task.customFields ?? {};
|
||||
const badges = cardDefs
|
||||
.map((f) => renderCardFieldBadge(f, values[f.id]))
|
||||
.filter((b): b is ReactElement => b !== null);
|
||||
if (badges.length === 0) return null;
|
||||
const shown = badges.slice(0, MAX_CARD_FIELDS);
|
||||
const overflow = badges.length - shown.length;
|
||||
return (
|
||||
<div className="card-field-badges" data-testid="card-field-badges">
|
||||
{shown}
|
||||
{overflow > 0 ? (
|
||||
<span className="card-field-badge card-field-badge--overflow" data-testid="card-field-overflow">
|
||||
+{overflow}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{hasBranchMetadata && (
|
||||
<div className="card-branch-row" aria-label={t("tasks.branchMetadata", "Branch metadata")}>
|
||||
{branchMetadata.branch && (
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
resolveTaskPlanningModel,
|
||||
resolveTaskValidatorModel,
|
||||
} from "@fusion/core";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus } from "../api";
|
||||
import type { RecoverBranchBindingOutcome } from "../api";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields } from "../api";
|
||||
import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
||||
import { ApiRequestError } from "../api";
|
||||
import { TaskFieldsSection } from "./TaskFieldsSection";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
@@ -605,6 +607,59 @@ export function TaskDetailContent({
|
||||
const [showRefineModal, setShowRefineModal] = useState(false);
|
||||
const [prCreateOpen, setPrCreateOpen] = useState(false);
|
||||
|
||||
// Custom field definitions (U13/KTD-14). Resolved for this task's workflow
|
||||
// from the board-workflows payload; absent when the workflow declares none,
|
||||
// in which case the fields section renders nothing (today's UI byte-identical).
|
||||
const [customFieldDefs, setCustomFieldDefs] = useState<WorkflowFieldDefinition[] | null>(null);
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(task.customFields ?? {});
|
||||
const [customFieldError, setCustomFieldError] = useState<CustomFieldRejection | null>(null);
|
||||
|
||||
// Keep local field values in sync when the task prop changes (SSE refresh).
|
||||
useEffect(() => {
|
||||
setCustomFieldValues(task.customFields ?? {});
|
||||
}, [task.id, task.customFields]);
|
||||
|
||||
// Resolve this task's workflow field definitions once per task. Best-effort:
|
||||
// a failed fetch (or flag-OFF empty payload) leaves defs null → no section.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchBoardWorkflows(projectId)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
const workflowId = payload.taskWorkflowIds[task.id] ?? payload.defaultWorkflowId;
|
||||
const workflow = payload.workflows.find((w) => w.id === workflowId);
|
||||
setCustomFieldDefs(workflow?.fields ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCustomFieldDefs(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [task.id, projectId]);
|
||||
|
||||
const handleSaveCustomFields = useCallback(
|
||||
async (patch: Record<string, unknown>) => {
|
||||
setCustomFieldError(null);
|
||||
try {
|
||||
const updated = await updateTaskCustomFields(task.id, patch, projectId);
|
||||
setCustomFieldValues(updated.customFields ?? {});
|
||||
onTaskUpdated?.(updated);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError && err.details && typeof err.details.fieldId === "string") {
|
||||
setCustomFieldError({
|
||||
code: (err.details.code as CustomFieldRejection["code"]) ?? "type-mismatch",
|
||||
fieldId: err.details.fieldId,
|
||||
detail: typeof err.details.detail === "string" ? err.details.detail : err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
addToast(getErrorMessage(err) || t("taskFields.saveFailed", "Failed to save field"), "error");
|
||||
}
|
||||
},
|
||||
[task.id, projectId, onTaskUpdated, addToast, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "logs" || logSubview !== "activity") {
|
||||
setHighlightStallCode(null);
|
||||
@@ -2485,6 +2540,15 @@ export function TaskDetailContent({
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{customFieldDefs && customFieldDefs.length > 0 ? (
|
||||
<TaskFieldsSection
|
||||
fieldDefs={customFieldDefs}
|
||||
customFields={customFieldValues}
|
||||
onSave={handleSaveCustomFields}
|
||||
error={customFieldError}
|
||||
readOnly={Boolean(task.column === "archived")}
|
||||
/>
|
||||
) : null}
|
||||
{showNearDuplicateWarning && (
|
||||
<div className="detail-near-duplicate-banner" role="status" aria-live="polite">
|
||||
<div className="detail-near-duplicate-banner__header">
|
||||
|
||||
214
packages/dashboard/app/components/TaskFieldsSection.css
Normal file
214
packages/dashboard/app/components/TaskFieldsSection.css
Normal file
@@ -0,0 +1,214 @@
|
||||
/* Schema-driven custom-field form section (U13 / KTD-14). */
|
||||
|
||||
.task-fields-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.task-field-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #8a8f98);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.task-field-required {
|
||||
color: var(--accent-danger, #e5484d);
|
||||
}
|
||||
|
||||
.task-field-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-field-input,
|
||||
.task-field-textarea,
|
||||
.task-field-select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color, #2a2d34);
|
||||
border-radius: 6px;
|
||||
background: var(--input-bg, #16181d);
|
||||
color: var(--text-primary, #e6e6e6);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.task-field-textarea {
|
||||
resize: vertical;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.task-field-input:disabled,
|
||||
.task-field-textarea:disabled,
|
||||
.task-field-select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Chips (enum single + multi-enum) */
|
||||
.task-field-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-field-chip {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--border-color, #2a2d34);
|
||||
border-radius: 999px;
|
||||
background: var(--chip-bg, #1c1f26);
|
||||
color: var(--text-secondary, #b4b8c0);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.task-field-chip:hover:not(:disabled) {
|
||||
border-color: var(--accent, #4f7cff);
|
||||
}
|
||||
|
||||
.task-field-chip.is-active {
|
||||
background: var(--accent, #4f7cff);
|
||||
border-color: var(--accent, #4f7cff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-field-chip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Radio group */
|
||||
.task-field-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-field-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e6e6e6);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Boolean toggle */
|
||||
.task-field-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-field-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.task-field-toggle-track {
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-color, #2a2d34);
|
||||
position: relative;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.task-field-toggle-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.task-field-toggle input:checked + .task-field-toggle-track {
|
||||
background: var(--accent, #4f7cff);
|
||||
}
|
||||
|
||||
.task-field-toggle input:checked + .task-field-toggle-track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.task-field-toggle input:disabled + .task-field-toggle-track {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Inline validation error */
|
||||
.task-field-error {
|
||||
font-size: 12px;
|
||||
color: var(--accent-danger, #e5484d);
|
||||
}
|
||||
|
||||
.task-field-row.has-error .task-field-input,
|
||||
.task-field-row.has-error .task-field-textarea,
|
||||
.task-field-row.has-error .task-field-select {
|
||||
border-color: var(--accent-danger, #e5484d);
|
||||
}
|
||||
|
||||
/* Collapsible detail-section group */
|
||||
.task-fields-group,
|
||||
.task-fields-orphaned {
|
||||
border-top: 1px solid var(--border-color, #2a2d34);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.task-fields-group-header,
|
||||
.task-fields-orphaned-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 4px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary, #8a8f98);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-fields-group-body,
|
||||
.task-fields-orphaned-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.task-fields-orphaned-count {
|
||||
margin-left: auto;
|
||||
background: var(--chip-bg, #1c1f26);
|
||||
border-radius: 999px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.task-field-orphaned-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #b4b8c0);
|
||||
word-break: break-word;
|
||||
}
|
||||
412
packages/dashboard/app/components/TaskFieldsSection.tsx
Normal file
412
packages/dashboard/app/components/TaskFieldsSection.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Schema-driven custom-field form section (U13 / KTD-14).
|
||||
*
|
||||
* Renders a task's workflow-defined custom fields ({@link WorkflowFieldDefinition})
|
||||
* as editable widgets, grouped by `render.placement`:
|
||||
* - `detail` (and the default when unset) → inline, near the description.
|
||||
* - `detail-section` → inside a collapsible group.
|
||||
* Card-placed fields (`placement: "card"`) are intentionally NOT rendered here —
|
||||
* those surface as badges on {@link TaskCard}.
|
||||
*
|
||||
* Widget selection (per `type` + optional `render.widget`):
|
||||
* - enum → select (default) | radio | chips (single-select)
|
||||
* - multi-enum → chips (multi-select)
|
||||
* - boolean → toggle
|
||||
* - date → date input
|
||||
* - url/number → validated <input>
|
||||
* - string → text input
|
||||
* - text → textarea
|
||||
*
|
||||
* Editing is per-field, save-on-commit (blur for inputs, change for
|
||||
* toggles/selects/chips/radio). Each save calls `onSave({ [fieldId]: value })`;
|
||||
* on a 400 the caller surfaces the typed rejection through `error`, which this
|
||||
* component renders inline beneath the offending field.
|
||||
*
|
||||
* Orphaned values — keys in `customFields` with no matching definition — render
|
||||
* read-only under a collapsed "Orphaned fields" disclosure (never destroyed,
|
||||
* KTD-13).
|
||||
*
|
||||
* Zero field definitions AND zero orphaned values → the component renders
|
||||
* nothing (null), so a task on a field-less workflow is byte-identical to
|
||||
* today's UI (snapshot-guarded by the test suite).
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
import type {
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowFieldOption,
|
||||
CustomFieldRejection,
|
||||
} from "../api";
|
||||
import "./TaskFieldsSection.css";
|
||||
|
||||
export interface TaskFieldsSectionProps {
|
||||
/** The task's workflow field definitions (from board-workflows payload). */
|
||||
fieldDefs: WorkflowFieldDefinition[];
|
||||
/** Current custom field values, keyed by field id. */
|
||||
customFields: Record<string, unknown>;
|
||||
/**
|
||||
* Persist a single-field patch. Resolves on success; the caller is expected
|
||||
* to throw / reject with the server's typed rejection so it can flow into
|
||||
* `error`. May be omitted to render read-only (e.g. archived tasks).
|
||||
*/
|
||||
onSave?: (patch: Record<string, unknown>) => Promise<void>;
|
||||
/**
|
||||
* The most recent typed rejection from a failed save (400), surfaced inline
|
||||
* beneath the matching field. Cleared by the caller on a successful save.
|
||||
*/
|
||||
error?: CustomFieldRejection | null;
|
||||
/** When true, fields render read-only (no edit affordances). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function optionLabel(field: WorkflowFieldDefinition, value: string): string {
|
||||
return field.options?.find((o) => o.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function optionColor(field: WorkflowFieldDefinition, value: string): string | undefined {
|
||||
return field.options?.find((o) => o.value === value)?.color;
|
||||
}
|
||||
|
||||
/** Resolve the effective widget for a field, applying the per-type default. */
|
||||
function resolveWidget(field: WorkflowFieldDefinition): NonNullable<WorkflowFieldDefinition["render"]>["widget"] {
|
||||
const explicit = field.render?.widget;
|
||||
if (explicit) return explicit;
|
||||
switch (field.type) {
|
||||
case "enum":
|
||||
return "select";
|
||||
case "multi-enum":
|
||||
return "chips";
|
||||
case "boolean":
|
||||
return "toggle";
|
||||
case "text":
|
||||
return "textarea";
|
||||
default:
|
||||
return "input";
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldRowProps {
|
||||
field: WorkflowFieldDefinition;
|
||||
value: unknown;
|
||||
onSave?: (patch: Record<string, unknown>) => Promise<void>;
|
||||
error?: CustomFieldRejection | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const widget = resolveWidget(field);
|
||||
const fieldError = error && error.fieldId === field.id ? error : null;
|
||||
const disabled = readOnly || !onSave;
|
||||
|
||||
const commit = useCallback(
|
||||
(next: unknown) => {
|
||||
if (!onSave) return;
|
||||
void onSave({ [field.id]: next });
|
||||
},
|
||||
[onSave, field.id],
|
||||
);
|
||||
|
||||
const labelId = `task-field-label-${field.id}`;
|
||||
const controlId = `task-field-${field.id}`;
|
||||
|
||||
const renderControl = () => {
|
||||
// enum → select / radio / chips (single)
|
||||
if (field.type === "enum") {
|
||||
const current = typeof value === "string" ? value : "";
|
||||
if (widget === "radio") {
|
||||
return (
|
||||
<div className="task-field-radio-group" role="radiogroup" aria-labelledby={labelId}>
|
||||
{(field.options ?? []).map((opt: WorkflowFieldOption) => (
|
||||
<label key={opt.value} className="task-field-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name={controlId}
|
||||
value={opt.value}
|
||||
checked={current === opt.value}
|
||||
disabled={disabled}
|
||||
onChange={() => commit(opt.value)}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (widget === "chips") {
|
||||
return (
|
||||
<div className="task-field-chips" role="group" aria-labelledby={labelId}>
|
||||
{(field.options ?? []).map((opt) => {
|
||||
const active = current === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`task-field-chip${active ? " is-active" : ""}`}
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
style={active && opt.color ? { backgroundColor: opt.color, borderColor: opt.color } : undefined}
|
||||
onClick={() => commit(active ? null : opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// default: select
|
||||
return (
|
||||
<select
|
||||
id={controlId}
|
||||
className="task-field-select"
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
onChange={(e) => commit(e.target.value === "" ? null : e.target.value)}
|
||||
>
|
||||
<option value="">{t("taskFields.unset", "—")}</option>
|
||||
{(field.options ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// multi-enum → chips (multi-select)
|
||||
if (field.type === "multi-enum") {
|
||||
const current = Array.isArray(value) ? (value as string[]) : [];
|
||||
return (
|
||||
<div className="task-field-chips" role="group" aria-labelledby={labelId}>
|
||||
{(field.options ?? []).map((opt) => {
|
||||
const active = current.includes(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`task-field-chip${active ? " is-active" : ""}`}
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
style={active && opt.color ? { backgroundColor: opt.color, borderColor: opt.color } : undefined}
|
||||
onClick={() => {
|
||||
const next = active
|
||||
? current.filter((v) => v !== opt.value)
|
||||
: [...current, opt.value];
|
||||
commit(next);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// boolean → toggle
|
||||
if (field.type === "boolean") {
|
||||
const checked = value === true;
|
||||
return (
|
||||
<label className="task-field-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={controlId}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
onChange={(e) => commit(e.target.checked)}
|
||||
/>
|
||||
<span className="task-field-toggle-track" aria-hidden="true" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// date → date input
|
||||
if (field.type === "date") {
|
||||
const current = typeof value === "string" ? value.slice(0, 10) : "";
|
||||
return (
|
||||
<input
|
||||
id={controlId}
|
||||
type="date"
|
||||
className="task-field-input"
|
||||
defaultValue={current}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value;
|
||||
if (next === current) return;
|
||||
commit(next === "" ? null : next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// text → textarea
|
||||
if (field.type === "text") {
|
||||
const current = typeof value === "string" ? value : "";
|
||||
return (
|
||||
<textarea
|
||||
id={controlId}
|
||||
className="task-field-textarea"
|
||||
defaultValue={current}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
rows={3}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value === current) return;
|
||||
commit(e.target.value === "" ? null : e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// number / url / string → validated input
|
||||
const current =
|
||||
field.type === "number"
|
||||
? (typeof value === "number" ? String(value) : "")
|
||||
: (typeof value === "string" ? value : "");
|
||||
return (
|
||||
<input
|
||||
id={controlId}
|
||||
type={field.type === "number" ? "number" : field.type === "url" ? "url" : "text"}
|
||||
className="task-field-input"
|
||||
defaultValue={current}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
onBlur={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === current) return;
|
||||
if (raw === "") {
|
||||
commit(null);
|
||||
return;
|
||||
}
|
||||
if (field.type === "number") {
|
||||
const num = Number(raw);
|
||||
commit(Number.isFinite(num) ? num : raw);
|
||||
return;
|
||||
}
|
||||
commit(raw);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`task-field-row${fieldError ? " has-error" : ""}`}
|
||||
data-testid={`task-field-row-${field.id}`}
|
||||
data-field-type={field.type}
|
||||
>
|
||||
<div className="task-field-label" id={labelId}>
|
||||
{field.name}
|
||||
{field.required ? <span className="task-field-required" aria-hidden="true"> *</span> : null}
|
||||
</div>
|
||||
<div className="task-field-control">{renderControl()}</div>
|
||||
{fieldError ? (
|
||||
<div className="task-field-error" role="alert" data-testid={`task-field-error-${field.id}`}>
|
||||
{fieldError.detail}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskFieldsSection({
|
||||
fieldDefs,
|
||||
customFields,
|
||||
onSave,
|
||||
error,
|
||||
readOnly = false,
|
||||
}: TaskFieldsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [sectionOpen, setSectionOpen] = useState(true);
|
||||
const [orphanedOpen, setOrphanedOpen] = useState(false);
|
||||
|
||||
const inlineFields = useMemo(
|
||||
() => fieldDefs.filter((f) => (f.render?.placement ?? "detail") === "detail"),
|
||||
[fieldDefs],
|
||||
);
|
||||
const sectionFields = useMemo(
|
||||
() => fieldDefs.filter((f) => f.render?.placement === "detail-section"),
|
||||
[fieldDefs],
|
||||
);
|
||||
|
||||
// Orphaned: stored keys with no matching definition (KTD-13). Card-placed
|
||||
// defs are excluded from the detail form, but their VALUES are not orphaned —
|
||||
// only keys with no def at all qualify.
|
||||
const orphaned = useMemo(() => {
|
||||
const defIds = new Set(fieldDefs.map((f) => f.id));
|
||||
return Object.entries(customFields ?? {}).filter(([id]) => !defIds.has(id));
|
||||
}, [fieldDefs, customFields]);
|
||||
|
||||
// Byte-identical-to-today guard: nothing to render at all.
|
||||
if (inlineFields.length === 0 && sectionFields.length === 0 && orphaned.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderRow = (field: WorkflowFieldDefinition) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={(customFields ?? {})[field.id]}
|
||||
onSave={onSave}
|
||||
error={error}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="task-fields-section" data-testid="task-fields-section">
|
||||
{inlineFields.map(renderRow)}
|
||||
|
||||
{sectionFields.length > 0 ? (
|
||||
<div className="task-fields-group">
|
||||
<button
|
||||
type="button"
|
||||
className="task-fields-group-header"
|
||||
aria-expanded={sectionOpen}
|
||||
data-testid="task-fields-group-toggle"
|
||||
onClick={() => setSectionOpen((o) => !o)}
|
||||
>
|
||||
{sectionOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t("taskFields.moreFields", "Additional fields")}</span>
|
||||
</button>
|
||||
{sectionOpen ? <div className="task-fields-group-body">{sectionFields.map(renderRow)}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{orphaned.length > 0 ? (
|
||||
<div className="task-fields-orphaned">
|
||||
<button
|
||||
type="button"
|
||||
className="task-fields-orphaned-header"
|
||||
aria-expanded={orphanedOpen}
|
||||
data-testid="task-fields-orphaned-toggle"
|
||||
onClick={() => setOrphanedOpen((o) => !o)}
|
||||
>
|
||||
{orphanedOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t("taskFields.orphaned", "Orphaned fields")}</span>
|
||||
<span className="task-fields-orphaned-count">{orphaned.length}</span>
|
||||
</button>
|
||||
{orphanedOpen ? (
|
||||
<div className="task-fields-orphaned-body" data-testid="task-fields-orphaned-body">
|
||||
{orphaned.map(([id, value]) => (
|
||||
<div key={id} className="task-field-row task-field-orphaned-row" data-testid={`task-field-orphaned-${id}`}>
|
||||
<div className="task-field-label">{id}</div>
|
||||
<div className="task-field-control task-field-orphaned-value">
|
||||
{Array.isArray(value) ? value.join(", ") : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskFieldsSection;
|
||||
@@ -28,6 +28,8 @@ interface WorktreeGroupProps {
|
||||
lastFetchTimeMs?: number;
|
||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Per-task card-placed custom field definitions (U13/KTD-14). */
|
||||
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
|
||||
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
|
||||
@@ -51,6 +53,7 @@ function WorktreeGroupComponent({
|
||||
onOpenMission,
|
||||
lastFetchTimeMs,
|
||||
workflowStepNameLookup,
|
||||
taskCardFieldDefs,
|
||||
blockerFanoutMap,
|
||||
prAuthAvailable,
|
||||
autoMergeEnabled,
|
||||
@@ -68,7 +71,7 @@ function WorktreeGroupComponent({
|
||||
<span className="worktree-label">{label}</span>
|
||||
</div>
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} />
|
||||
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} cardFieldDefs={taskCardFieldDefs?.get(task.id)} fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} />
|
||||
))}
|
||||
{queuedTasks.map((task) => (
|
||||
<TaskCard
|
||||
@@ -86,6 +89,7 @@ function WorktreeGroupComponent({
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={autoMergeEnabled}
|
||||
|
||||
@@ -4532,3 +4532,84 @@ describe("TaskCard agent badge", () => {
|
||||
expect(screen.queryByTitle(/Assigned to/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard custom field badges (U13/KTD-14)", () => {
|
||||
type FieldDef = import("../../api").WorkflowFieldDefinition;
|
||||
const cardDef = (over: Partial<FieldDef> & Pick<FieldDef, "id" | "name" | "type">): FieldDef => ({
|
||||
render: { placement: "card" },
|
||||
...over,
|
||||
});
|
||||
|
||||
it("renders no badges and stays byte-identical when no field defs are passed", () => {
|
||||
const { container: withTask } = render(
|
||||
<TaskCard task={makeTask({ customFields: { x: "y" } })} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
expect(withTask.querySelector('[data-testid="card-field-badges"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an enum badge with the option color and label", () => {
|
||||
const defs: FieldDef[] = [
|
||||
cardDef({ id: "sev", name: "Severity", type: "enum", options: [{ value: "high", label: "High", color: "#ef4444" }] }),
|
||||
];
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ customFields: { sev: "high" } })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
cardFieldDefs={defs}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByText("High");
|
||||
expect(badge.getAttribute("style")).toContain("rgb(239, 68, 68)");
|
||||
});
|
||||
|
||||
it("renders a labeled chip for boolean true and nothing for false", () => {
|
||||
const defs: FieldDef[] = [cardDef({ id: "blk", name: "Blocked", type: "boolean" })];
|
||||
const { rerender } = render(
|
||||
<TaskCard task={makeTask({ customFields: { blk: true } })} onOpenDetail={noop} addToast={noop} cardFieldDefs={defs} />,
|
||||
);
|
||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||
rerender(
|
||||
<TaskCard task={makeTask({ customFields: { blk: false } })} onOpenDetail={noop} addToast={noop} cardFieldDefs={defs} />,
|
||||
);
|
||||
expect(screen.queryByTestId("card-field-badges")).toBeNull();
|
||||
});
|
||||
|
||||
it("caps at 3 badges and shows a +N overflow indicator", () => {
|
||||
const defs: FieldDef[] = [
|
||||
cardDef({ id: "a", name: "A", type: "string" }),
|
||||
cardDef({ id: "b", name: "B", type: "string" }),
|
||||
cardDef({ id: "c", name: "C", type: "string" }),
|
||||
cardDef({ id: "d", name: "D", type: "string" }),
|
||||
cardDef({ id: "e", name: "E", type: "string" }),
|
||||
];
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ customFields: { a: "1", b: "2", c: "3", d: "4", e: "5" } })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
cardFieldDefs={defs}
|
||||
/>,
|
||||
);
|
||||
const overflow = screen.getByTestId("card-field-overflow");
|
||||
expect(overflow.textContent).toBe("+2");
|
||||
// Exactly 3 value badges + 1 overflow chip.
|
||||
const container = screen.getByTestId("card-field-badges");
|
||||
expect(container.querySelectorAll(".card-field-badge").length).toBe(4);
|
||||
});
|
||||
|
||||
it("ignores non-card-placed defs", () => {
|
||||
const defs: FieldDef[] = [
|
||||
{ id: "detailOnly", name: "Detail", type: "string", render: { placement: "detail" } },
|
||||
];
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ customFields: { detailOnly: "x" } })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
cardFieldDefs={defs}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("card-field-badges")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
makeTask,
|
||||
noop,
|
||||
noopDelete,
|
||||
noopMerge,
|
||||
noopMove,
|
||||
noopOpenDetail,
|
||||
setupTaskDetailModalHooks,
|
||||
} from "./TaskDetailModal.test-helpers";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
import * as dashboardApi from "../../api";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
|
||||
setupTaskDetailModalHooks();
|
||||
|
||||
function renderModal(task = makeTask({ column: "done" })) {
|
||||
return render(
|
||||
<FileBrowserProvider openFile={vi.fn()}>
|
||||
<TaskDetailModal
|
||||
task={task}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaskDetailModal custom fields (U13/KTD-14)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("renders no fields section when the workflow declares no fields (today's UI)", async () => {
|
||||
vi.spyOn(dashboardApi, "fetchBoardWorkflows").mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [{ id: "builtin:coding", name: "Coding", columns: [] }],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
renderModal();
|
||||
// Allow the field-defs fetch to settle.
|
||||
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("task-fields-section")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the schema-driven fields section when the workflow declares fields", async () => {
|
||||
vi.spyOn(dashboardApi, "fetchBoardWorkflows").mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [],
|
||||
fields: [
|
||||
{ id: "owner", name: "Owner", type: "string", render: { placement: "detail" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "builtin:coding" },
|
||||
});
|
||||
renderModal(makeTask({ id: "FN-001", column: "done", customFields: { owner: "alice" } }));
|
||||
await waitFor(() => expect(screen.getByTestId("task-fields-section")).toBeTruthy());
|
||||
expect((screen.getByLabelText("Owner") as HTMLInputElement).value).toBe("alice");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TaskFieldsSection } from "../TaskFieldsSection";
|
||||
import type { WorkflowFieldDefinition, CustomFieldRejection } from "../../api";
|
||||
|
||||
const enumField: WorkflowFieldDefinition = {
|
||||
id: "severity",
|
||||
name: "Severity",
|
||||
type: "enum",
|
||||
options: [
|
||||
{ value: "low", label: "Low", color: "#22c55e" },
|
||||
{ value: "high", label: "High", color: "#ef4444" },
|
||||
],
|
||||
render: { placement: "detail", widget: "select" },
|
||||
};
|
||||
|
||||
describe("TaskFieldsSection", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("renders nothing when there are no fields and no orphaned values (today's UI)", () => {
|
||||
const { container } = render(
|
||||
<TaskFieldsSection fieldDefs={[]} customFields={{}} onSave={vi.fn()} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when only card-placed fields exist (those go on the card)", () => {
|
||||
const { container } = render(
|
||||
<TaskFieldsSection
|
||||
fieldDefs={[{ id: "x", name: "X", type: "string", render: { placement: "card" } }]}
|
||||
customFields={{}}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("enum select renders options and edits via onSave", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskFieldsSection fieldDefs={[enumField]} customFields={{ severity: "low" }} onSave={onSave} />);
|
||||
const select = screen.getByLabelText("Severity") as HTMLSelectElement;
|
||||
expect(select.value).toBe("low");
|
||||
fireEvent.change(select, { target: { value: "high" } });
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ severity: "high" }));
|
||||
});
|
||||
|
||||
it("enum radio widget commits the chosen option", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const field: WorkflowFieldDefinition = { ...enumField, render: { placement: "detail", widget: "radio" } };
|
||||
render(<TaskFieldsSection fieldDefs={[field]} customFields={{}} onSave={onSave} />);
|
||||
fireEvent.click(screen.getByLabelText("High"));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ severity: "high" }));
|
||||
});
|
||||
|
||||
it("enum chips widget toggles selection and applies option color", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const field: WorkflowFieldDefinition = { ...enumField, render: { placement: "detail", widget: "chips" } };
|
||||
render(<TaskFieldsSection fieldDefs={[field]} customFields={{ severity: "high" }} onSave={onSave} />);
|
||||
const highChip = screen.getByRole("button", { name: "High" });
|
||||
// Enum color applied to the active chip.
|
||||
expect(highChip.getAttribute("style")).toContain("rgb(239, 68, 68)");
|
||||
// Clicking the active chip clears it (commits null).
|
||||
fireEvent.click(highChip);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ severity: null }));
|
||||
});
|
||||
|
||||
it("multi-enum chips add/remove members", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const field: WorkflowFieldDefinition = {
|
||||
id: "tags",
|
||||
name: "Tags",
|
||||
type: "multi-enum",
|
||||
options: [
|
||||
{ value: "a", label: "Alpha" },
|
||||
{ value: "b", label: "Beta" },
|
||||
],
|
||||
render: { placement: "detail" },
|
||||
};
|
||||
render(<TaskFieldsSection fieldDefs={[field]} customFields={{ tags: ["a"] }} onSave={onSave} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Beta" }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tags: ["a", "b"] }));
|
||||
});
|
||||
|
||||
it("boolean toggle commits true/false", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const field: WorkflowFieldDefinition = { id: "done", name: "Done", type: "boolean", render: { placement: "detail" } };
|
||||
render(<TaskFieldsSection fieldDefs={[field]} customFields={{ done: false }} onSave={onSave} />);
|
||||
fireEvent.click(screen.getByLabelText("Done"));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ done: true }));
|
||||
});
|
||||
|
||||
it("string input commits on blur", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const field: WorkflowFieldDefinition = { id: "owner", name: "Owner", type: "string", render: { placement: "detail" } };
|
||||
render(<TaskFieldsSection fieldDefs={[field]} customFields={{}} onSave={onSave} />);
|
||||
const input = screen.getByLabelText("Owner") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "alice" } });
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ owner: "alice" }));
|
||||
});
|
||||
|
||||
it("text widget renders a textarea and commits on blur", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const field: WorkflowFieldDefinition = { id: "notes", name: "Notes", type: "text", render: { placement: "detail" } };
|
||||
render(<TaskFieldsSection fieldDefs={[field]} customFields={{}} onSave={onSave} />);
|
||||
const ta = screen.getByLabelText("Notes") as HTMLTextAreaElement;
|
||||
expect(ta.tagName).toBe("TEXTAREA");
|
||||
fireEvent.change(ta, { target: { value: "hi" } });
|
||||
fireEvent.blur(ta);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ notes: "hi" }));
|
||||
});
|
||||
|
||||
it("number input commits a numeric value", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const field: WorkflowFieldDefinition = { id: "count", name: "Count", type: "number", render: { placement: "detail" } };
|
||||
render(<TaskFieldsSection fieldDefs={[field]} customFields={{}} onSave={onSave} />);
|
||||
const input = screen.getByLabelText("Count") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "42" } });
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ count: 42 }));
|
||||
});
|
||||
|
||||
it("url and date inputs render with the correct input type", () => {
|
||||
const fields: WorkflowFieldDefinition[] = [
|
||||
{ id: "link", name: "Link", type: "url", render: { placement: "detail" } },
|
||||
{ id: "due", name: "Due", type: "date", render: { placement: "detail" } },
|
||||
];
|
||||
render(<TaskFieldsSection fieldDefs={fields} customFields={{ due: "2026-06-04T00:00:00.000Z" }} onSave={vi.fn()} />);
|
||||
expect((screen.getByLabelText("Link") as HTMLInputElement).type).toBe("url");
|
||||
const due = screen.getByLabelText("Due") as HTMLInputElement;
|
||||
expect(due.type).toBe("date");
|
||||
expect(due.value).toBe("2026-06-04");
|
||||
});
|
||||
|
||||
it("surfaces the typed rejection inline beneath the offending field", () => {
|
||||
const error: CustomFieldRejection = { code: "enum-violation", fieldId: "severity", detail: "value not allowed" };
|
||||
render(<TaskFieldsSection fieldDefs={[enumField]} customFields={{}} onSave={vi.fn()} error={error} />);
|
||||
expect(screen.getByTestId("task-field-error-severity").textContent).toBe("value not allowed");
|
||||
expect(screen.getByTestId("task-field-row-severity").className).toContain("has-error");
|
||||
});
|
||||
|
||||
it("groups detail-section fields under a collapsible disclosure", () => {
|
||||
const fields: WorkflowFieldDefinition[] = [
|
||||
{ id: "a", name: "Inline", type: "string", render: { placement: "detail" } },
|
||||
{ id: "b", name: "Sectioned", type: "string", render: { placement: "detail-section" } },
|
||||
];
|
||||
render(<TaskFieldsSection fieldDefs={fields} customFields={{}} onSave={vi.fn()} />);
|
||||
// Both visible while the section is open by default.
|
||||
expect(screen.getByLabelText("Inline")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Sectioned")).toBeTruthy();
|
||||
// Collapsing hides the sectioned field but keeps the inline one.
|
||||
fireEvent.click(screen.getByTestId("task-fields-group-toggle"));
|
||||
expect(screen.queryByLabelText("Sectioned")).toBeNull();
|
||||
expect(screen.getByLabelText("Inline")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders orphaned values read-only under a collapsed disclosure", () => {
|
||||
render(
|
||||
<TaskFieldsSection
|
||||
fieldDefs={[enumField]}
|
||||
customFields={{ severity: "low", legacyField: "stale" }}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Disclosure present but collapsed by default → body hidden.
|
||||
expect(screen.getByTestId("task-fields-orphaned-toggle")).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-fields-orphaned-body")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("task-fields-orphaned-toggle"));
|
||||
const body = screen.getByTestId("task-fields-orphaned-body");
|
||||
expect(body.textContent).toContain("legacyField");
|
||||
expect(body.textContent).toContain("stale");
|
||||
});
|
||||
|
||||
it("does not call onSave when readOnly", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<TaskFieldsSection fieldDefs={[enumField]} customFields={{ severity: "low" }} onSave={onSave} readOnly />);
|
||||
const select = screen.getByLabelText("Severity") as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// @vitest-environment node
|
||||
//
|
||||
// U13 / KTD-14: HTTP coverage for custom task fields.
|
||||
// - PATCH /tasks/:id/custom-fields validates a value patch through the store
|
||||
// write authority (updateTaskCustomFields): a valid patch returns 200 with
|
||||
// the updated task; an enum violation returns 400 with { fieldId, code,
|
||||
// detail }; an unknown field returns 400; a malformed body returns 400.
|
||||
// - GET /tasks/board-workflows carries the workflow's `fields` declaration in
|
||||
// each described workflow definition (flag ON).
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import type { WorkflowIr } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { buildBoardWorkflowsPayload } from "../board-workflows.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
/** A linear v2 workflow declaring two custom fields (KTD-13). */
|
||||
function fieldedWorkflow(name: string): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name,
|
||||
columns: [
|
||||
{ id: "c-intake", name: "Intake", traits: [{ trait: "intake" }] },
|
||||
{ id: "c-run", name: "Run", traits: [{ trait: "wip", config: { limit: 5 } }] },
|
||||
{ id: "c-done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "c-intake" },
|
||||
{ id: "end", kind: "end", column: "c-done" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
fields: [
|
||||
{
|
||||
id: "severity",
|
||||
name: "Severity",
|
||||
type: "enum",
|
||||
options: [
|
||||
{ value: "low", label: "Low", color: "#22c55e" },
|
||||
{ value: "high", label: "High", color: "#ef4444" },
|
||||
],
|
||||
render: { placement: "card" },
|
||||
},
|
||||
{ id: "owner", name: "Owner", type: "string", render: { placement: "detail" } },
|
||||
],
|
||||
} as WorkflowIr;
|
||||
}
|
||||
|
||||
describe("custom task fields routes (U13/KTD-14)", () => {
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "cf-route-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "cf-route-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const patch = (path: string, body: unknown) =>
|
||||
REQUEST(app, "PATCH", path, JSON.stringify(body), { "content-type": "application/json" });
|
||||
const get = (path: string) => REQUEST(app, "GET", path);
|
||||
|
||||
async function taskWithFields() {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Fielded", ir: fieldedWorkflow("fielded") });
|
||||
const task = await store.createTask({ description: "card" });
|
||||
await store.selectTaskWorkflowAndReconcile(task.id, wf.id);
|
||||
return { wf, task };
|
||||
}
|
||||
|
||||
it("PATCH custom-fields accepts a valid patch and returns the updated task", async () => {
|
||||
const { task } = await taskWithFields();
|
||||
const res = await patch(`/api/tasks/${task.id}/custom-fields`, {
|
||||
customFields: { severity: "high", owner: "alice" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { id: string; customFields: Record<string, unknown> };
|
||||
expect(body.id).toBe(task.id);
|
||||
expect(body.customFields.severity).toBe("high");
|
||||
expect(body.customFields.owner).toBe("alice");
|
||||
});
|
||||
|
||||
it("PATCH custom-fields rejects an enum violation with 400 { fieldId, code, detail }", async () => {
|
||||
const { task } = await taskWithFields();
|
||||
const res = await patch(`/api/tasks/${task.id}/custom-fields`, {
|
||||
customFields: { severity: "nope" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const details = (res.body as { details?: { fieldId?: string; code?: string; detail?: string } }).details;
|
||||
expect(details?.fieldId).toBe("severity");
|
||||
expect(details?.code).toBe("enum-violation");
|
||||
expect(typeof details?.detail).toBe("string");
|
||||
});
|
||||
|
||||
it("PATCH custom-fields rejects an unknown field with 400 unknown-field", async () => {
|
||||
const { task } = await taskWithFields();
|
||||
const res = await patch(`/api/tasks/${task.id}/custom-fields`, {
|
||||
customFields: { nonexistent: "x" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const details = (res.body as { details?: { fieldId?: string; code?: string } }).details;
|
||||
expect(details?.fieldId).toBe("nonexistent");
|
||||
expect(details?.code).toBe("unknown-field");
|
||||
});
|
||||
|
||||
it("PATCH custom-fields rejects a malformed body with 400", async () => {
|
||||
const { task } = await taskWithFields();
|
||||
const res = await patch(`/api/tasks/${task.id}/custom-fields`, { customFields: "not-an-object" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PATCH custom-fields deletes a value via null", async () => {
|
||||
const { task } = await taskWithFields();
|
||||
await patch(`/api/tasks/${task.id}/custom-fields`, { customFields: { owner: "alice" } });
|
||||
const res = await patch(`/api/tasks/${task.id}/custom-fields`, { customFields: { owner: null } });
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { customFields: Record<string, unknown> };
|
||||
expect(body.customFields.owner).toBeUndefined();
|
||||
});
|
||||
|
||||
it("board-workflows payload (flag ON) carries the workflow's fields declaration", async () => {
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
|
||||
const { wf, task } = await taskWithFields();
|
||||
// Drive the payload builder with the explicit task-id set the route would
|
||||
// pass — isolates the fields pass-through from the route's slim-list read
|
||||
// (subject to the known startup-slim-memo staleness, see board-workflows-route.test).
|
||||
const payload = await buildBoardWorkflowsPayload(store, [task.id]);
|
||||
expect(payload.flagEnabled).toBe(true);
|
||||
const fielded = payload.workflows.find((w) => w.id === wf.id) as
|
||||
| { id: string; fields?: Array<{ id: string; type: string; render?: { placement?: string } }> }
|
||||
| undefined;
|
||||
expect(fielded?.fields).toBeDefined();
|
||||
expect(fielded?.fields?.map((f) => f.id).sort()).toEqual(["owner", "severity"]);
|
||||
const severity = fielded?.fields?.find((f) => f.id === "severity");
|
||||
expect(severity?.render?.placement).toBe("card");
|
||||
});
|
||||
|
||||
it("GET /tasks/board-workflows route returns 200 with flagEnabled true", async () => {
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
|
||||
await taskWithFields();
|
||||
const res = await get("/api/tasks/board-workflows");
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { flagEnabled: boolean }).flagEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,25 @@ import {
|
||||
type WorkflowIrV2,
|
||||
} from "@fusion/core";
|
||||
|
||||
/** A workflow-defined custom task field as the board client needs it (U13/
|
||||
* KTD-14). Structurally mirrors core's `WorkflowFieldDefinition`; declared
|
||||
* locally because the core field-schema types are not exported through the
|
||||
* `@fusion/core` barrel. The payload is a verbatim pass-through of the IR's
|
||||
* `fields` array. */
|
||||
export interface BoardWorkflowField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "string" | "text" | "number" | "boolean" | "enum" | "multi-enum" | "date" | "url";
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
options?: Array<{ value: string; label: string; color?: string }>;
|
||||
render?: {
|
||||
placement?: "card" | "detail" | "detail-section";
|
||||
widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle";
|
||||
badge?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable id the client uses for the implicit default lane (null selection). */
|
||||
export const DEFAULT_WORKFLOW_LANE_ID = "builtin:coding";
|
||||
|
||||
@@ -45,6 +64,9 @@ export interface BoardWorkflowDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
columns: BoardWorkflowColumn[];
|
||||
/** Custom field definitions declared by the workflow (U13/KTD-14). Absent
|
||||
* when the workflow declares no fields. */
|
||||
fields?: BoardWorkflowField[];
|
||||
}
|
||||
|
||||
/** The full board-workflows payload. `flagEnabled: false` short-circuits the
|
||||
@@ -73,6 +95,16 @@ function describeColumns(ir: WorkflowIr): BoardWorkflowColumn[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Pass through the workflow's declared custom fields (U13/KTD-14). Returns
|
||||
* `undefined` when the workflow declares none, so the payload stays compact and
|
||||
* byte-identical for field-less workflows. */
|
||||
function describeFields(ir: WorkflowIr): BoardWorkflowField[] | undefined {
|
||||
const v2 = toV2(ir);
|
||||
const fields = v2?.fields;
|
||||
if (!fields || fields.length === 0) return undefined;
|
||||
return fields as BoardWorkflowField[];
|
||||
}
|
||||
|
||||
async function describeWorkflow(
|
||||
store: Pick<TaskStore, "getWorkflowDefinition">,
|
||||
workflowId: string,
|
||||
@@ -82,7 +114,8 @@ async function describeWorkflow(
|
||||
if (isBuiltinWorkflowId(workflowId)) {
|
||||
const ir = await resolveWorkflowIrById(store, workflowId);
|
||||
const name = getBuiltinWorkflow(workflowId)?.name ?? ir.name;
|
||||
return { id: workflowId, name, columns: describeColumns(ir) };
|
||||
const fields = describeFields(ir);
|
||||
return { id: workflowId, name, columns: describeColumns(ir), ...(fields ? { fields } : {}) };
|
||||
}
|
||||
// Custom workflow: fetch the definition once and derive both IR and name from
|
||||
// it (previously getWorkflowDefinition was called twice per workflow).
|
||||
@@ -97,7 +130,8 @@ async function describeWorkflow(
|
||||
} catch {
|
||||
// fall through to the default IR/name
|
||||
}
|
||||
return { id: workflowId, name, columns: describeColumns(ir) };
|
||||
const fields = describeFields(ir);
|
||||
return { id: workflowId, name, columns: describeColumns(ir), ...(fields ? { fields } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3217,6 +3217,53 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
// Patch a task's custom field values (U13/KTD-14). Delegates to the single
|
||||
// store write authority (`updateTaskCustomFields`), which validates the patch
|
||||
// against the task's workflow field schema. A typed rejection surfaces as a
|
||||
// 400 carrying `{ fieldId, code, detail }` so the dashboard can render an
|
||||
// inline per-field error. `null`/`undefined` values delete the field.
|
||||
router.patch("/tasks/:id/custom-fields", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const body = req.body as { customFields?: unknown };
|
||||
const patch = body?.customFields;
|
||||
if (patch === undefined || patch === null || typeof patch !== "object" || Array.isArray(patch)) {
|
||||
throw badRequest("customFields must be an object");
|
||||
}
|
||||
|
||||
const storeWithFields = scopedStore as TaskStore & {
|
||||
updateTaskCustomFields?: (
|
||||
taskId: string,
|
||||
patch: Record<string, unknown>,
|
||||
) => Promise<{ ok: true; task: Task } | { ok: false; rejection: { code: string; fieldId: string; detail: string } }>;
|
||||
};
|
||||
if (typeof storeWithFields.updateTaskCustomFields !== "function") {
|
||||
throw notFound("custom fields unavailable");
|
||||
}
|
||||
|
||||
const result = await storeWithFields.updateTaskCustomFields(
|
||||
req.params.id,
|
||||
patch as Record<string, unknown>,
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw new ApiError(400, result.rejection.detail, {
|
||||
fieldId: result.rejection.fieldId,
|
||||
code: result.rejection.code,
|
||||
detail: result.rejection.detail,
|
||||
});
|
||||
}
|
||||
res.json(result.task);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT" || (err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Accept review - clear assignee and awaiting-user-review status, keep in in-review
|
||||
router.post("/tasks/:id/accept-review", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -165,12 +165,14 @@ const qualityAppComponentTests = [
|
||||
"TaskDetailModal",
|
||||
"TaskDetailModal.allow-resurrection",
|
||||
"TaskDetailModal.create-pr-e2e",
|
||||
"TaskDetailModal.custom-fields",
|
||||
"TestModeBanner",
|
||||
"TaskDetailModal.create-pr-integration",
|
||||
"TaskDetailModal.github-tracking-header",
|
||||
"TaskDetailModal.github-tracking-stale",
|
||||
"TaskDetailModal.rebind-banner",
|
||||
"TaskDocumentsTab",
|
||||
"TaskFieldsSection",
|
||||
"TaskForm",
|
||||
"TaskIdIntegrityBanner",
|
||||
"TrackingRepoSelect",
|
||||
|
||||
@@ -6792,5 +6792,11 @@
|
||||
"installRequestTitle": "Worktrunk install request",
|
||||
"sha256": "SHA-256",
|
||||
"version": "Version"
|
||||
},
|
||||
"taskFields": {
|
||||
"unset": "—",
|
||||
"moreFields": "Additional fields",
|
||||
"orphaned": "Orphaned fields",
|
||||
"saveFailed": "Failed to save field"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6792,5 +6792,11 @@
|
||||
"installRequestTitle": "Solicitud de instalación de Worktrunk",
|
||||
"sha256": "SHA-256",
|
||||
"version": "Versión"
|
||||
},
|
||||
"taskFields": {
|
||||
"unset": "—",
|
||||
"moreFields": "Campos adicionales",
|
||||
"orphaned": "Campos huérfanos",
|
||||
"saveFailed": "No se pudo guardar el campo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6792,5 +6792,11 @@
|
||||
"installRequestTitle": "Demande d'installation de Worktrunk",
|
||||
"sha256": "SHA-256",
|
||||
"version": "Version"
|
||||
},
|
||||
"taskFields": {
|
||||
"unset": "—",
|
||||
"moreFields": "Champs supplémentaires",
|
||||
"orphaned": "Champs orphelins",
|
||||
"saveFailed": "Échec de l'enregistrement du champ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6792,5 +6792,11 @@
|
||||
"installRequestTitle": "Worktrunk 설치 요청",
|
||||
"sha256": "SHA-256",
|
||||
"version": "버전"
|
||||
},
|
||||
"taskFields": {
|
||||
"unset": "—",
|
||||
"moreFields": "추가 필드",
|
||||
"orphaned": "고아 필드",
|
||||
"saveFailed": "필드 저장 실패"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6792,5 +6792,11 @@
|
||||
"installRequestTitle": "Worktrunk 安装请求",
|
||||
"sha256": "SHA-256",
|
||||
"version": "版本"
|
||||
},
|
||||
"taskFields": {
|
||||
"unset": "—",
|
||||
"moreFields": "其他字段",
|
||||
"orphaned": "孤立字段",
|
||||
"saveFailed": "保存字段失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6792,5 +6792,11 @@
|
||||
"installRequestTitle": "Worktrunk 安裝請求",
|
||||
"sha256": "SHA-256",
|
||||
"version": "版本"
|
||||
},
|
||||
"taskFields": {
|
||||
"unset": "—",
|
||||
"moreFields": "其他欄位",
|
||||
"orphaned": "孤立欄位",
|
||||
"saveFailed": "儲存欄位失敗"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user