Add in-progress state to tasks

Introduce IN_PROGRESS as a new task state between TODO and DONE across
the full stack: database enum, Go backend, GraphQL, MCP, and frontend.

The task state icon now cycles forward on click (TODO → IN_PROGRESS →
DONE → TODO), and the action dropdown provides explicit "Move to"
options for any state transition. The "All" tab supports drag-and-drop
between state sections to change a task's state.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-02 15:50:20 +02:00
parent 5c71575ade
commit 8adf26ad20
9 changed files with 340 additions and 169 deletions

View File

@@ -27,11 +27,12 @@ import {
PriorityLevel, PriorityLevel,
PropertyRow, PropertyRow,
Select, Select,
TaskStateIcon,
Textarea, Textarea,
useDialogRef, useDialogRef,
} from "@probo/ui"; } from "@probo/ui";
import { Breadcrumb } from "@probo/ui"; import { Breadcrumb } from "@probo/ui";
import type { ReactNode } from "react"; import { type ReactNode, useEffect } from "react";
import { Controller } from "react-hook-form"; import { Controller } from "react-hook-form";
import { useFragment, useRelayEnvironment } from "react-relay"; import { useFragment, useRelayEnvironment } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
@@ -50,6 +51,7 @@ const taskFragment = graphql`
id id
description description
name name
state
priority priority
timeEstimate timeEstimate
deadline deadline
@@ -91,6 +93,7 @@ export const taskUpdateMutation = graphql`
} }
`; `;
export const taskStates = ["TODO", "IN_PROGRESS", "DONE"] as const;
export const taskPriorities = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; export const taskPriorities = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
const createTaskSchema = z.object({ const createTaskSchema = z.object({
@@ -109,6 +112,7 @@ const createTaskSchema = z.object({
const updateTaskSchema = z.object({ const updateTaskSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
state: z.enum(taskStates),
priority: z.enum(taskPriorities), priority: z.enum(taskPriorities),
timeEstimate: z.string().optional().nullable(), timeEstimate: z.string().optional().nullable(),
assignedToId: z.preprocess( assignedToId: z.preprocess(
@@ -154,6 +158,7 @@ export default function TaskFormDialog(props: Props) {
defaultValues: { defaultValues: {
name: task?.name ?? "", name: task?.name ?? "",
description: task?.description ?? "", description: task?.description ?? "",
state: task?.state ?? "TODO",
priority: task?.priority ?? "MEDIUM", priority: task?.priority ?? "MEDIUM",
timeEstimate: task?.timeEstimate ?? "", timeEstimate: task?.timeEstimate ?? "",
assignedToId: task?.assignedTo?.id ?? "", assignedToId: task?.assignedTo?.id ?? "",
@@ -162,6 +167,23 @@ export default function TaskFormDialog(props: Props) {
}, },
}); });
useEffect(() => {
if (task) {
reset({
name: task.name,
description: task.description ?? "",
state: task.state,
priority: task.priority,
timeEstimate: task.timeEstimate ?? "",
assignedToId: task.assignedTo?.id ?? "",
measureId: task.measure?.id ?? measureId ?? "",
deadline: task.deadline?.split("T")[0] ?? "",
});
}
}, [
task, reset, measureId,
]);
const onSubmit = async (data: z.infer<typeof updateTaskSchema | typeof createTaskSchema>) => { const onSubmit = async (data: z.infer<typeof updateTaskSchema | typeof createTaskSchema>) => {
if (task) { if (task) {
await mutate({ await mutate({
@@ -170,6 +192,7 @@ export default function TaskFormDialog(props: Props) {
taskId: task.id, taskId: task.id,
name: data.name, name: data.name,
description: data.description || null, description: data.description || null,
state: "state" in data ? data.state : undefined,
priority: data.priority, priority: data.priority,
timeEstimate: data.timeEstimate || null, timeEstimate: data.timeEstimate || null,
deadline: formatDatetime(data.deadline) ?? null, deadline: formatDatetime(data.deadline) ?? null,
@@ -242,6 +265,42 @@ export default function TaskFormDialog(props: Props) {
{/* Properties form */} {/* Properties form */}
<div className="py-5 px-6 bg-subtle"> <div className="py-5 px-6 bg-subtle">
<Label>{__("Properties")}</Label> <Label>{__("Properties")}</Label>
{isUpdating && (
<PropertyRow
label={__("State")}
error={"state" in formState.errors ? formState.errors.state?.message : undefined}
>
<Controller
name="state"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
>
<Option value="TODO">
<span className="flex items-center gap-2">
<TaskStateIcon state="TODO" />
{__("To do")}
</span>
</Option>
<Option value="IN_PROGRESS">
<span className="flex items-center gap-2">
<TaskStateIcon state="IN_PROGRESS" />
{__("In progress")}
</span>
</Option>
<Option value="DONE">
<span className="flex items-center gap-2">
<TaskStateIcon state="DONE" />
{__("Done")}
</span>
</Option>
</Select>
)}
/>
</PropertyRow>
)}
<PropertyRow <PropertyRow
label={__("Priority")} label={__("Priority")}
error={formState.errors.priority?.message} error={formState.errors.priority?.message}

View File

@@ -15,14 +15,14 @@
import { formatDate, formatDuration, formatError, promisifyMutation } from "@probo/helpers"; import { formatDate, formatDuration, formatError, promisifyMutation } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
ActionDropdown, Button,
Card, Card,
DropdownItem,
IconArrowCornerDownLeft, IconArrowCornerDownLeft,
IconCircleCheck,
IconCircleProgress,
IconPencil, IconPencil,
IconTrashCan, IconTrashCan,
PriorityLevel, PriorityLevel,
Spinner,
TabBadge, TabBadge,
TabItem, TabItem,
Tabs, Tabs,
@@ -31,7 +31,7 @@ import {
useDialogRef, useDialogRef,
useToast, useToast,
} from "@probo/ui"; } from "@probo/ui";
import { Fragment, type ReactNode, useState, useTransition } from "react"; import { Fragment, type ReactNode, useRef, useState, useTransition } from "react";
import { import {
graphql, graphql,
readInlineData, readInlineData,
@@ -67,16 +67,17 @@ function resolveDropPriority(
above?: TaskPriority, above?: TaskPriority,
below?: TaskPriority, below?: TaskPriority,
): TaskPriority | undefined { ): TaskPriority | undefined {
// If any neighbor shares the dragged priority, keep it.
if (above === dragged || below === dragged) return undefined; if (above === dragged || below === dragged) return undefined;
// At edges, take the single neighbor's priority. const di = taskPriorities.indexOf(dragged);
if (!above && below) return below !== dragged ? below : undefined;
if (!below && above) return above !== dragged ? above : undefined;
// Both neighbors differ — pick the one closest to dragged. if (!above && below) {
return taskPriorities.indexOf(below) <= di ? below : undefined;
}
if (!below && above) {
return taskPriorities.indexOf(above) >= di ? above : undefined;
}
if (above && below) { if (above && below) {
const di = taskPriorities.indexOf(dragged);
const dAbove = Math.abs(taskPriorities.indexOf(above) - di); const dAbove = Math.abs(taskPriorities.indexOf(above) - di);
const dBelow = Math.abs(taskPriorities.indexOf(below) - di); const dBelow = Math.abs(taskPriorities.indexOf(below) - di);
return dAbove <= dBelow ? above : below; return dAbove <= dBelow ? above : below;
@@ -171,6 +172,7 @@ const updateRankMutation = graphql`
id id
priority priority
rank rank
state
} }
} }
} }
@@ -184,7 +186,9 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const [draggedId, setDraggedId] = useState<string | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const [previewOrder, setPreviewOrder] = useState<string[] | null>(null); const [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
const [dropTargetState, setDropTargetState] = useState<string | null>(null);
const [updateRank] = useMutation<TaskFormDialogUpdateMutation>(updateRankMutation); const [updateRank] = useMutation<TaskFormDialogUpdateMutation>(updateRankMutation);
const droppedRef = useRef(false);
const handleStateChange = () => { const handleStateChange = () => {
if (refetch) { if (refetch) {
@@ -194,31 +198,56 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
} }
}; };
const hashes = [ const stateHashes = [
{ hash: "", label: __("To do"), state: "TODO" }, { hash: "todo", label: __("To do"), state: "TODO" },
{ hash: "in-progress", label: __("In progress"), state: "IN_PROGRESS" },
{ hash: "done", label: __("Done"), state: "DONE" }, { hash: "done", label: __("Done"), state: "DONE" },
{ hash: "all", label: __("All"), state: null },
] as const; ] as const;
const tasksPerHash = new Map([ const hashes = [
["", tasks?.filter(({ node }) => readTask(node).state === "TODO")], { hash: "", label: __("All"), state: null },
["done", tasks?.filter(({ node }) => readTask(node).state === "DONE")], ...stateHashes,
["all", tasks], ] as const;
const tasksPerHash = new Map<string, typeof tasks>([
...stateHashes.map(h => [h.hash, tasks?.filter(({ node }) => readTask(node).state === h.state)] as const),
["", tasks],
]); ]);
const filteredTasks = tasksPerHash.get(hash) ?? []; const filteredTasks = tasksPerHash.get(hash) ?? [];
const canDrag = !!canReorder && hash !== "all"; const canDrag = !!canReorder;
const handleDragOver = (e: React.DragEvent, hoveredId: string) => { // Get the task list for a given state section.
const sectionTasks = (state: string) =>
tasks?.filter(({ node }) => readTask(node).state === state) ?? [];
const handleDragOver = (e: React.DragEvent, hoveredId: string, hoveredState?: string) => {
e.preventDefault(); e.preventDefault();
if (draggedId === null || hoveredId === draggedId) return; if (draggedId === null || hoveredId === draggedId) return;
const ids = filteredTasks.map(({ node }) => readTask(node).id);
if (hoveredState) setDropTargetState(hoveredState);
// Reorder within the target section (works for both All and single-state tabs).
const sectionList = hash === "" && hoveredState
? sectionTasks(hoveredState)
: filteredTasks;
const ids = sectionList.map(({ node }) => readTask(node).id);
const fromIdx = ids.indexOf(draggedId); const fromIdx = ids.indexOf(draggedId);
if (fromIdx === -1) return;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2; const midY = rect.top + rect.height / 2;
const insertBefore = e.clientY < midY; const insertBefore = e.clientY < midY;
const hoverIdx = ids.indexOf(hoveredId); const hoverIdx = ids.indexOf(hoveredId);
if (fromIdx === -1) {
// Dragging from another section — insert relative to the hovered task.
const targetIdx = insertBefore ? hoverIdx : hoverIdx + 1;
const reordered = [...ids];
reordered.splice(targetIdx, 0, draggedId);
setPreviewOrder(reordered);
return;
}
let targetIdx = insertBefore ? hoverIdx : hoverIdx + 1; let targetIdx = insertBefore ? hoverIdx : hoverIdx + 1;
if (targetIdx > fromIdx) targetIdx--; if (targetIdx > fromIdx) targetIdx--;
if (targetIdx === fromIdx) { if (targetIdx === fromIdx) {
@@ -232,82 +261,147 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
}; };
const handleDrop = () => { const handleDrop = () => {
if (draggedId === null || previewOrder === null) { if (draggedId === null) {
setDraggedId(null); resetDragState();
return; return;
} }
const newIdx = previewOrder.indexOf(draggedId); if (previewOrder === null && !(hash === "" && dropTargetState)) {
const originalIds = filteredTasks.map(({ node }) => readTask(node).id); resetDragState();
const originalIdx = originalIds.indexOf(draggedId);
if (originalIdx === -1) {
setDraggedId(null);
setPreviewOrder(null);
return; return;
} }
// Determine which section list to resolve rank/priority from.
const targetState = hash === "" ? dropTargetState : null;
const sectionList = targetState ? sectionTasks(targetState) : filteredTasks;
const sectionIds = sectionList.map(({ node }) => readTask(node).id);
const byId = new Map(tasks.map(edge => [readTask(edge.node).id, edge]));
// Use previewOrder when available, otherwise the section list.
// Append draggedId if missing (cross-section drop onto a header with no preview).
const order = previewOrder ?? (sectionIds.includes(draggedId) ? sectionIds : [...sectionIds, draggedId]);
const newIdx = order.indexOf(draggedId);
if (newIdx === -1) {
resetDragState();
return;
}
// Find the task we're displacing to get its rank.
const originalIdx = sectionIds.indexOf(draggedId);
let targetOriginalIdx = newIdx; let targetOriginalIdx = newIdx;
if (targetOriginalIdx >= originalIdx) targetOriginalIdx++; if (originalIdx !== -1) {
if (targetOriginalIdx >= filteredTasks.length) targetOriginalIdx = filteredTasks.length - 1; if (targetOriginalIdx >= originalIdx) targetOriginalIdx++;
const targetTask = readTask(filteredTasks[targetOriginalIdx].node); if (targetOriginalIdx >= sectionList.length) targetOriginalIdx = sectionList.length - 1;
const draggedTask = readTask(filteredTasks[originalIdx].node); } else {
// Cross-section drop: clamp to section bounds.
if (targetOriginalIdx >= sectionList.length) targetOriginalIdx = sectionList.length - 1;
}
// Determine target priority from neighbors at the drop position. const draggedEdge = byId.get(draggedId);
const aboveId = newIdx > 0 ? previewOrder[newIdx - 1] : null; if (!draggedEdge) {
const belowId = newIdx < previewOrder.length - 1 ? previewOrder[newIdx + 1] : null; resetDragState();
const aboveTask = aboveId ? readTask(filteredTasks[originalIds.indexOf(aboveId)].node) : null; return;
const belowTask = belowId ? readTask(filteredTasks[originalIds.indexOf(belowId)].node) : null; }
const targetPriority = resolveDropPriority(draggedTask.priority, aboveTask?.priority, belowTask?.priority); const draggedTask = readTask(draggedEdge.node);
setDraggedId(null); // Determine target rank from the displaced task, or default to rank 1 for empty sections.
const targetRank = sectionList.length > 0
? readTask(sectionList[Math.max(0, targetOriginalIdx)].node).rank
: 1;
// Determine if state changed (All tab cross-section drop).
const newState = targetState && targetState !== draggedTask.state
? targetState as "TODO" | "IN_PROGRESS" | "DONE"
: undefined;
// Only change priority for same-state reorder, never for cross-section drops.
const aboveId = newIdx > 0 ? order[newIdx - 1] : null;
const belowId = newIdx < order.length - 1 ? order[newIdx + 1] : null;
const aboveTask = aboveId && byId.has(aboveId) ? readTask(byId.get(aboveId)!.node) : null;
const belowTask = belowId && byId.has(belowId) ? readTask(byId.get(belowId)!.node) : null;
const targetPriority = newState
? undefined
: resolveDropPriority(draggedTask.priority, aboveTask?.priority, belowTask?.priority);
const taskId = draggedId;
droppedRef.current = true;
updateRank({ updateRank({
variables: { variables: {
input: { input: {
taskId: draggedId, taskId,
rank: targetTask.rank, rank: targetRank,
...(targetPriority && { priority: targetPriority }), ...(targetPriority && { priority: targetPriority }),
...(newState && { state: newState }),
}, },
}, },
onCompleted: (_, errors) => { onCompleted: (_, errors) => {
if (errors?.length) { if (errors?.length) {
toast({ toast({
title: __("Error"), title: __("Error"),
description: formatError( description: formatError(__("Failed to reorder task."), errors),
__("Failed to reorder task."),
errors,
),
variant: "error", variant: "error",
}); });
} }
if (refetch) { if (refetch) {
startTransition(() => { startTransition(() => {
refetch( refetch({}, { fetchPolicy: errors?.length ? "network-only" : "store-and-network" });
{}, droppedRef.current = false;
{ fetchPolicy: errors?.length ? "network-only" : "store-and-network" }, resetDragState();
);
}); });
} else {
droppedRef.current = false;
resetDragState();
} }
}, },
onError: () => { onError: () => {
toast({ droppedRef.current = false;
title: __("Error"), resetDragState();
description: __("Failed to reorder task."), toast({ title: __("Error"), description: __("Failed to reorder task."), variant: "error" });
variant: "error",
});
}, },
}); });
}; };
const displayTasks = (() => { const resetDragState = () => {
if (!previewOrder) return filteredTasks; setDraggedId(null);
const byId = new Map(filteredTasks.map(edge => [readTask(edge.node).id, edge])); setPreviewOrder(null);
const currentIdSet = new Set(byId.keys()); setDropTargetState(null);
const previewIdSet = new Set(previewOrder); };
if (currentIdSet.size !== previewIdSet.size || [...currentIdSet].some(id => !previewIdSet.has(id))) {
return filteredTasks; const byId = new Map(tasks.map(edge => [readTask(edge.node).id, edge]));
}
return previewOrder.map(id => byId.get(id)!); const applyPreviewOrder = (sourceTasks: typeof tasks) => {
})(); if (!previewOrder) return sourceTasks;
const sourceIds = new Set(sourceTasks.map(({ node }) => readTask(node).id));
// Check if the preview order matches this section (may include the dragged item from another section).
const previewMatchesSection = previewOrder.every(id => sourceIds.has(id) || id === draggedId);
if (!previewMatchesSection) return sourceTasks;
return previewOrder.filter(id => byId.has(id)).map(id => byId.get(id)!);
};
const displayTasks = applyPreviewOrder(filteredTasks);
const renderTaskRow = (node: (typeof tasks)[number]["node"], sectionState?: "TODO" | "IN_PROGRESS" | "DONE") => {
const task = readTask(node);
return (
<TaskRow
key={task.id}
fKey={node}
connectionId={connectionId}
sectionState={sectionState}
canDrag={canDrag}
isDragging={draggedId === task.id}
isGhost={previewOrder !== null && draggedId === task.id}
onDragStart={() => setDraggedId(task.id)}
onDragOver={e => handleDragOver(e, task.id, sectionState)}
onDrop={handleDrop}
onDragEnd={() => { if (!droppedRef.current) resetDragState(); }}
onStateChange={handleStateChange}
/>
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -321,6 +415,7 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
{hashes.map(h => ( {hashes.map(h => (
<TabItem asChild active={hash === h.hash} key={h.hash}> <TabItem asChild active={hash === h.hash} key={h.hash}>
<Link to={`#${h.hash}`}> <Link to={`#${h.hash}`}>
{h.state && <TaskStateIcon state={h.state} />}
{h.label} {h.label}
<TabBadge>{tasksPerHash.get(h.hash)?.length}</TabBadge> <TabBadge>{tasksPerHash.get(h.hash)?.length}</TabBadge>
</Link> </Link>
@@ -328,52 +423,42 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
))} ))}
</Tabs> </Tabs>
<div className="divide-y divide-border-solid"> <div className="divide-y divide-border-solid">
{hash === "all" {hash === ""
// All tabs group the todo using the state ? stateHashes
? hashes .filter(h => tasksPerHash.get(h.hash)?.length || (draggedId && dropTargetState === h.state))
.slice(0, 2) .map((h) => {
.filter(h => tasksPerHash.get(h.hash)?.length) const displayEdges = applyPreviewOrder(tasksPerHash.get(h.hash) ?? []);
.map(h => ( const dragClass = canDrag && draggedId !== null
<Fragment key={h.label}> ? "border-2 border-dashed border-transparent hover:border-primary-300"
<h2 className="px-6 py-3 text-sm font-medium flex items-center gap-2 bg-subtle"> : "";
<TaskStateIcon state={h.state!} /> return (
{h.label} <Fragment key={h.label}>
</h2> <h2
{tasksPerHash.get(h.hash)?.map(({ node }) => ( className={`px-6 py-3 text-sm font-medium flex items-center gap-2 bg-subtle ${dragClass}`}
<TaskRow onDragOver={canDrag
key={readTask(node).id} ? (e) => {
fKey={node} e.preventDefault();
connectionId={connectionId} setDropTargetState(h.state);
onStateChange={handleStateChange} }
/> : undefined}
))} onDrop={canDrag ? handleDrop : undefined}
</Fragment> >
)) <TaskStateIcon state={h.state} />
// Todo and Done tab simply list todos {h.label}
: displayTasks.map(({ node }) => { </h2>
const task = readTask(node); {displayEdges.map(({ node }) => renderTaskRow(node, h.state))}
return ( </Fragment>
<TaskRow );
key={task.id} })
fKey={node} : displayTasks.map(({ node }) => renderTaskRow(node))}
connectionId={connectionId}
canDrag={canDrag}
isDragging={draggedId === task.id}
isGhost={previewOrder !== null && draggedId === task.id}
onDragStart={() => setDraggedId(task.id)}
onDragOver={e => handleDragOver(e, task.id)}
onDrop={handleDrop}
onDragEnd={() => setDraggedId(null)}
onStateChange={handleStateChange}
/>
);
})}
</div> </div>
</Card> </Card>
)} )}
{canDrag && filteredTasks.length > 1 && ( {canDrag && filteredTasks.length > 1 && (
<p className="text-sm text-txt-tertiary"> <p className="text-sm text-txt-tertiary">
{__("Drag and drop to reorder tasks")} {hash === ""
? __("Drag and drop to reorder tasks or move them between states")
: __("Drag and drop to reorder tasks")}
</p> </p>
)} )}
</div> </div>
@@ -383,6 +468,7 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
type TaskRowProps = { type TaskRowProps = {
fKey: TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key; fKey: TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key;
connectionId: string; connectionId: string;
sectionState?: "TODO" | "IN_PROGRESS" | "DONE";
canDrag?: boolean; canDrag?: boolean;
isDragging?: boolean; isDragging?: boolean;
isGhost?: boolean; isGhost?: boolean;
@@ -440,16 +526,29 @@ function TaskRow(props: TaskRowProps) {
fragment, fragment,
props.fKey as TasksCard_TaskRowFragment$key, props.fKey as TasksCard_TaskRowFragment$key,
); );
const [updateTask, isUpdating] = useMutation<TaskFormDialogUpdateMutation>(taskUpdateMutation); const [updateTask, isAdvancing] = useMutation<TaskFormDialogUpdateMutation>(taskUpdateMutation);
const [isMouseDown, setIsMouseDown] = useState(false); const [isMouseDown, setIsMouseDown] = useState(false);
const displayState = props.sectionState ?? task.state;
const onToggle = async () => { const nextStepConfig: Record<string, {
state: "IN_PROGRESS" | "DONE";
label: string;
icon: typeof IconCircleProgress;
className: string;
}> = {
TODO: { state: "IN_PROGRESS", label: __("Move to In progress"), icon: IconCircleProgress, className: "text-txt-warning" },
IN_PROGRESS: { state: "DONE", label: __("Move to Done"), icon: IconCircleCheck, className: "text-txt-accent" },
};
const onAdvance = async () => {
const config = nextStepConfig[displayState];
if (!config) return;
const target = config.state;
await promisifyMutation(updateTask)({ await promisifyMutation(updateTask)({
variables: { variables: {
input: { input: {
taskId: task.id, taskId: task.id,
state: task.state === "TODO" ? "DONE" : "TODO", state: target,
}, },
}, },
}); });
@@ -481,12 +580,10 @@ function TaskRow(props: TaskRowProps) {
); );
}; };
const canDrag = props.canDrag; const { canDrag, isDragging, isGhost } = props;
const isDragging = props.isDragging;
const isGhost = props.isGhost;
const className = [ const className = [
"transition-all duration-150", canDrag && "select-none",
canDrag && isDragging && !isGhost && "opacity-40 cursor-grabbing", canDrag && isDragging && !isGhost && "opacity-40 cursor-grabbing",
canDrag && !isDragging && !isMouseDown && "cursor-grab", canDrag && !isDragging && !isMouseDown && "cursor-grab",
canDrag && !isDragging && isMouseDown && "cursor-grabbing", canDrag && !isDragging && isMouseDown && "cursor-grabbing",
@@ -516,13 +613,7 @@ function TaskRow(props: TaskRowProps) {
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">
<div className="flex items-center gap-2 pt-[2px]"> <div className="flex items-center gap-2 pt-[2px]">
<PriorityLevel level={task.priority} /> <PriorityLevel level={task.priority} />
<button <TaskStateIcon state={displayState} />
onClick={() => void onToggle()}
className="cursor-pointer -m-1 p-1 disabled:opacity-60"
disabled={isUpdating}
>
<TaskStateIcon state={task.state} />
</button>
</div> </div>
<div className="text-sm space-y-1 flex-1"> <div className="text-sm space-y-1 flex-1">
<h2 className="font-medium">{task.name}</h2> <h2 className="font-medium">{task.name}</h2>
@@ -566,27 +657,31 @@ function TaskRow(props: TaskRowProps) {
</div> </div>
)} )}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{isUpdating && <Spinner size={16} />} {canUpdate && nextStepConfig[displayState] && (
{(canUpdate || canDelete) && ( <Button
<ActionDropdown> variant="secondary"
{canUpdate && ( icon={nextStepConfig[displayState].icon}
<DropdownItem className={nextStepConfig[displayState].className}
icon={IconPencil} title={nextStepConfig[displayState].label}
onClick={() => dialogRef.current?.open()} onClick={() => void onAdvance()}
> disabled={isAdvancing}
{__("Edit")} />
</DropdownItem> )}
)} {canUpdate && (
{canDelete && ( <Button
<DropdownItem variant="secondary"
variant="danger" icon={IconPencil}
icon={IconTrashCan} title={__("Edit")}
onClick={onDelete} onClick={() => dialogRef.current?.open()}
> />
{__("Delete")} )}
</DropdownItem> {canDelete && (
)} <Button
</ActionDropdown> variant="danger"
icon={IconTrashCan}
title={__("Delete")}
onClick={onDelete}
/>
)} )}
</div> </div>
</div> </div>

View File

@@ -320,6 +320,7 @@ func TestTask_StateEnum(t *testing.T) {
states := []string{ states := []string{
"TODO", "TODO",
"IN_PROGRESS",
"DONE", "DONE",
} }

View File

@@ -30,6 +30,12 @@ export const Default: Story = {
}, },
}; };
export const InProgress: Story = {
args: {
state: "IN_PROGRESS",
},
};
export const Done: Story = { export const Done: Story = {
args: { args: {
state: "DONE", state: "DONE",

View File

@@ -12,19 +12,21 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { IconCircleCheck } from "./IconCircleCheck";
import { IconCircleProgress } from "./IconCircleProgress"; import { IconCircleProgress } from "./IconCircleProgress";
import { IconRadioUnchecked } from "./IconRadioUnchecked"; import { IconRadioUnchecked } from "./IconRadioUnchecked";
type Props = { type Props = {
state: "TODO" | "DONE"; state: "TODO" | "IN_PROGRESS" | "DONE";
}; };
export function TaskStateIcon({ state }: Props) { export function TaskStateIcon({ state }: Props) {
return state === "TODO" switch (state) {
? ( case "TODO":
<IconRadioUnchecked size={16} className="text-txt-quaternary" /> return <IconRadioUnchecked size={16} className="text-txt-quaternary" />;
) case "IN_PROGRESS":
: ( return <IconCircleProgress size={16} className="text-txt-warning" />;
<IconCircleProgress size={16} className="text-txt-accent" /> case "DONE":
); return <IconCircleCheck size={16} className="text-txt-accent" />;
}
} }

View File

@@ -0,0 +1,15 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TYPE task_state ADD VALUE 'IN_PROGRESS' BEFORE 'DONE';

View File

@@ -20,17 +20,19 @@ import (
) )
type ( type (
TaskState uint8 TaskState string
) )
const ( const (
TaskStateTodo TaskState = iota TaskStateTodo TaskState = "TODO"
TaskStateDone TaskStateInProgress TaskState = "IN_PROGRESS"
TaskStateDone TaskState = "DONE"
) )
func TaskStates() []TaskState { func TaskStates() []TaskState {
return []TaskState{ return []TaskState{
TaskStateTodo, TaskStateTodo,
TaskStateInProgress,
TaskStateDone, TaskStateDone,
} }
} }
@@ -40,13 +42,11 @@ func (ts TaskState) MarshalText() ([]byte, error) {
} }
func (ts *TaskState) UnmarshalText(data []byte) error { func (ts *TaskState) UnmarshalText(data []byte) error {
val := string(data) val := TaskState(data)
switch val { switch val {
case TaskStateTodo.String(): case TaskStateTodo, TaskStateInProgress, TaskStateDone:
*ts = TaskStateTodo *ts = val
case TaskStateDone.String():
*ts = TaskStateDone
default: default:
return fmt.Errorf("invalid TaskState value: %q", val) return fmt.Errorf("invalid TaskState value: %q", val)
} }
@@ -55,16 +55,7 @@ func (ts *TaskState) UnmarshalText(data []byte) error {
} }
func (ts TaskState) String() string { func (ts TaskState) String() string {
var val string return string(ts)
switch ts {
case TaskStateTodo:
val = "TODO"
case TaskStateDone:
val = "DONE"
}
return val
} }
func (ts *TaskState) Scan(value any) error { func (ts *TaskState) Scan(value any) error {

View File

@@ -66,6 +66,7 @@ enum MeasureState
enum TaskState @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskState") { enum TaskState @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskState") {
TODO @goEnum(value: "go.probo.inc/probo/pkg/coredata.TaskStateTodo") TODO @goEnum(value: "go.probo.inc/probo/pkg/coredata.TaskStateTodo")
IN_PROGRESS @goEnum(value: "go.probo.inc/probo/pkg/coredata.TaskStateInProgress")
DONE @goEnum(value: "go.probo.inc/probo/pkg/coredata.TaskStateDone") DONE @goEnum(value: "go.probo.inc/probo/pkg/coredata.TaskStateDone")
} }

View File

@@ -4615,6 +4615,7 @@ components:
type: string type: string
enum: enum:
- TODO - TODO
- IN_PROGRESS
- DONE - DONE
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskState go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskState