diff --git a/apps/console/src/components/tasks/TaskFormDialog.tsx b/apps/console/src/components/tasks/TaskFormDialog.tsx index 94ef72def..946005787 100644 --- a/apps/console/src/components/tasks/TaskFormDialog.tsx +++ b/apps/console/src/components/tasks/TaskFormDialog.tsx @@ -27,11 +27,12 @@ import { PriorityLevel, PropertyRow, Select, + TaskStateIcon, Textarea, useDialogRef, } 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 { useFragment, useRelayEnvironment } from "react-relay"; import { graphql } from "relay-runtime"; @@ -50,6 +51,7 @@ const taskFragment = graphql` id description name + state priority timeEstimate 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; const createTaskSchema = z.object({ @@ -109,6 +112,7 @@ const createTaskSchema = z.object({ const updateTaskSchema = z.object({ name: z.string().min(1), description: z.string().optional().nullable(), + state: z.enum(taskStates), priority: z.enum(taskPriorities), timeEstimate: z.string().optional().nullable(), assignedToId: z.preprocess( @@ -154,6 +158,7 @@ export default function TaskFormDialog(props: Props) { defaultValues: { name: task?.name ?? "", description: task?.description ?? "", + state: task?.state ?? "TODO", priority: task?.priority ?? "MEDIUM", timeEstimate: task?.timeEstimate ?? "", 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) => { if (task) { await mutate({ @@ -170,6 +192,7 @@ export default function TaskFormDialog(props: Props) { taskId: task.id, name: data.name, description: data.description || null, + state: "state" in data ? data.state : undefined, priority: data.priority, timeEstimate: data.timeEstimate || null, deadline: formatDatetime(data.deadline) ?? null, @@ -242,6 +265,42 @@ export default function TaskFormDialog(props: Props) { {/* Properties form */}
+ {isUpdating && ( + + ( + + )} + /> + + )} = di ? above : undefined; + } if (above && below) { - const di = taskPriorities.indexOf(dragged); const dAbove = Math.abs(taskPriorities.indexOf(above) - di); const dBelow = Math.abs(taskPriorities.indexOf(below) - di); return dAbove <= dBelow ? above : below; @@ -171,6 +172,7 @@ const updateRankMutation = graphql` id priority rank + state } } } @@ -184,7 +186,9 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) { const { toast } = useToast(); const [draggedId, setDraggedId] = useState(null); const [previewOrder, setPreviewOrder] = useState(null); + const [dropTargetState, setDropTargetState] = useState(null); const [updateRank] = useMutation(updateRankMutation); + const droppedRef = useRef(false); const handleStateChange = () => { if (refetch) { @@ -194,31 +198,56 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) { } }; - const hashes = [ - { hash: "", label: __("To do"), state: "TODO" }, + const stateHashes = [ + { hash: "todo", label: __("To do"), state: "TODO" }, + { hash: "in-progress", label: __("In progress"), state: "IN_PROGRESS" }, { hash: "done", label: __("Done"), state: "DONE" }, - { hash: "all", label: __("All"), state: null }, ] as const; - const tasksPerHash = new Map([ - ["", tasks?.filter(({ node }) => readTask(node).state === "TODO")], - ["done", tasks?.filter(({ node }) => readTask(node).state === "DONE")], - ["all", tasks], + const hashes = [ + { hash: "", label: __("All"), state: null }, + ...stateHashes, + ] as const; + + const tasksPerHash = new Map([ + ...stateHashes.map(h => [h.hash, tasks?.filter(({ node }) => readTask(node).state === h.state)] as const), + ["", tasks], ]); 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(); 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); - if (fromIdx === -1) return; const rect = e.currentTarget.getBoundingClientRect(); const midY = rect.top + rect.height / 2; const insertBefore = e.clientY < midY; 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; if (targetIdx > fromIdx) targetIdx--; if (targetIdx === fromIdx) { @@ -232,82 +261,147 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) { }; const handleDrop = () => { - if (draggedId === null || previewOrder === null) { - setDraggedId(null); + if (draggedId === null) { + resetDragState(); return; } - const newIdx = previewOrder.indexOf(draggedId); - const originalIds = filteredTasks.map(({ node }) => readTask(node).id); - const originalIdx = originalIds.indexOf(draggedId); - if (originalIdx === -1) { - setDraggedId(null); - setPreviewOrder(null); + if (previewOrder === null && !(hash === "" && dropTargetState)) { + resetDragState(); 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; - if (targetOriginalIdx >= originalIdx) targetOriginalIdx++; - if (targetOriginalIdx >= filteredTasks.length) targetOriginalIdx = filteredTasks.length - 1; - const targetTask = readTask(filteredTasks[targetOriginalIdx].node); - const draggedTask = readTask(filteredTasks[originalIdx].node); + if (originalIdx !== -1) { + if (targetOriginalIdx >= originalIdx) targetOriginalIdx++; + if (targetOriginalIdx >= sectionList.length) targetOriginalIdx = sectionList.length - 1; + } 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 aboveId = newIdx > 0 ? previewOrder[newIdx - 1] : null; - const belowId = newIdx < previewOrder.length - 1 ? previewOrder[newIdx + 1] : null; - const aboveTask = aboveId ? readTask(filteredTasks[originalIds.indexOf(aboveId)].node) : null; - const belowTask = belowId ? readTask(filteredTasks[originalIds.indexOf(belowId)].node) : null; - const targetPriority = resolveDropPriority(draggedTask.priority, aboveTask?.priority, belowTask?.priority); + const draggedEdge = byId.get(draggedId); + if (!draggedEdge) { + resetDragState(); + return; + } + 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({ variables: { input: { - taskId: draggedId, - rank: targetTask.rank, + taskId, + rank: targetRank, ...(targetPriority && { priority: targetPriority }), + ...(newState && { state: newState }), }, }, onCompleted: (_, errors) => { if (errors?.length) { toast({ title: __("Error"), - description: formatError( - __("Failed to reorder task."), - errors, - ), + description: formatError(__("Failed to reorder task."), errors), variant: "error", }); } if (refetch) { startTransition(() => { - refetch( - {}, - { fetchPolicy: errors?.length ? "network-only" : "store-and-network" }, - ); + refetch({}, { fetchPolicy: errors?.length ? "network-only" : "store-and-network" }); + droppedRef.current = false; + resetDragState(); }); + } else { + droppedRef.current = false; + resetDragState(); } }, onError: () => { - toast({ - title: __("Error"), - description: __("Failed to reorder task."), - variant: "error", - }); + droppedRef.current = false; + resetDragState(); + toast({ title: __("Error"), description: __("Failed to reorder task."), variant: "error" }); }, }); }; - const displayTasks = (() => { - if (!previewOrder) return filteredTasks; - const byId = new Map(filteredTasks.map(edge => [readTask(edge.node).id, edge])); - const currentIdSet = new Set(byId.keys()); - const previewIdSet = new Set(previewOrder); - if (currentIdSet.size !== previewIdSet.size || [...currentIdSet].some(id => !previewIdSet.has(id))) { - return filteredTasks; - } - return previewOrder.map(id => byId.get(id)!); - })(); + const resetDragState = () => { + setDraggedId(null); + setPreviewOrder(null); + setDropTargetState(null); + }; + + const byId = new Map(tasks.map(edge => [readTask(edge.node).id, edge])); + + 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 ( + setDraggedId(task.id)} + onDragOver={e => handleDragOver(e, task.id, sectionState)} + onDrop={handleDrop} + onDragEnd={() => { if (!droppedRef.current) resetDragState(); }} + onStateChange={handleStateChange} + /> + ); + }; return (
@@ -321,6 +415,7 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) { {hashes.map(h => ( + {h.state && } {h.label} {tasksPerHash.get(h.hash)?.length} @@ -328,52 +423,42 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) { ))}
- {hash === "all" - // All tabs group the todo using the state - ? hashes - .slice(0, 2) - .filter(h => tasksPerHash.get(h.hash)?.length) - .map(h => ( - -

- - {h.label} -

- {tasksPerHash.get(h.hash)?.map(({ node }) => ( - - ))} -
- )) - // Todo and Done tab simply list todos - : displayTasks.map(({ node }) => { - const task = readTask(node); - return ( - setDraggedId(task.id)} - onDragOver={e => handleDragOver(e, task.id)} - onDrop={handleDrop} - onDragEnd={() => setDraggedId(null)} - onStateChange={handleStateChange} - /> - ); - })} + {hash === "" + ? stateHashes + .filter(h => tasksPerHash.get(h.hash)?.length || (draggedId && dropTargetState === h.state)) + .map((h) => { + const displayEdges = applyPreviewOrder(tasksPerHash.get(h.hash) ?? []); + const dragClass = canDrag && draggedId !== null + ? "border-2 border-dashed border-transparent hover:border-primary-300" + : ""; + return ( + +

{ + e.preventDefault(); + setDropTargetState(h.state); + } + : undefined} + onDrop={canDrag ? handleDrop : undefined} + > + + {h.label} +

+ {displayEdges.map(({ node }) => renderTaskRow(node, h.state))} +
+ ); + }) + : displayTasks.map(({ node }) => renderTaskRow(node))}
)} {canDrag && filteredTasks.length > 1 && (

- {__("Drag and drop to reorder tasks")} + {hash === "" + ? __("Drag and drop to reorder tasks or move them between states") + : __("Drag and drop to reorder tasks")}

)}
@@ -383,6 +468,7 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) { type TaskRowProps = { fKey: TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key; connectionId: string; + sectionState?: "TODO" | "IN_PROGRESS" | "DONE"; canDrag?: boolean; isDragging?: boolean; isGhost?: boolean; @@ -440,16 +526,29 @@ function TaskRow(props: TaskRowProps) { fragment, props.fKey as TasksCard_TaskRowFragment$key, ); - const [updateTask, isUpdating] = useMutation(taskUpdateMutation); - + const [updateTask, isAdvancing] = useMutation(taskUpdateMutation); const [isMouseDown, setIsMouseDown] = useState(false); + const displayState = props.sectionState ?? task.state; - const onToggle = async () => { + const nextStepConfig: Record = { + 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)({ variables: { input: { taskId: task.id, - state: task.state === "TODO" ? "DONE" : "TODO", + state: target, }, }, }); @@ -481,12 +580,10 @@ function TaskRow(props: TaskRowProps) { ); }; - const canDrag = props.canDrag; - const isDragging = props.isDragging; - const isGhost = props.isGhost; + const { canDrag, isDragging, isGhost } = props; const className = [ - "transition-all duration-150", + canDrag && "select-none", canDrag && isDragging && !isGhost && "opacity-40 cursor-grabbing", canDrag && !isDragging && !isMouseDown && "cursor-grab", canDrag && !isDragging && isMouseDown && "cursor-grabbing", @@ -516,13 +613,7 @@ function TaskRow(props: TaskRowProps) {
- +

{task.name}

@@ -566,27 +657,31 @@ function TaskRow(props: TaskRowProps) {
)}
- {isUpdating && } - {(canUpdate || canDelete) && ( - - {canUpdate && ( - dialogRef.current?.open()} - > - {__("Edit")} - - )} - {canDelete && ( - - {__("Delete")} - - )} - + {canUpdate && nextStepConfig[displayState] && ( +
diff --git a/e2e/console/task_test.go b/e2e/console/task_test.go index 26153cf59..7129e2e57 100644 --- a/e2e/console/task_test.go +++ b/e2e/console/task_test.go @@ -320,6 +320,7 @@ func TestTask_StateEnum(t *testing.T) { states := []string{ "TODO", + "IN_PROGRESS", "DONE", } diff --git a/packages/ui/src/Atoms/Icons/TaskStateIcon.stories.tsx b/packages/ui/src/Atoms/Icons/TaskStateIcon.stories.tsx index 819401b1e..03f7029c4 100644 --- a/packages/ui/src/Atoms/Icons/TaskStateIcon.stories.tsx +++ b/packages/ui/src/Atoms/Icons/TaskStateIcon.stories.tsx @@ -30,6 +30,12 @@ export const Default: Story = { }, }; +export const InProgress: Story = { + args: { + state: "IN_PROGRESS", + }, +}; + export const Done: Story = { args: { state: "DONE", diff --git a/packages/ui/src/Atoms/Icons/TaskStateIcon.tsx b/packages/ui/src/Atoms/Icons/TaskStateIcon.tsx index b44e2242e..7cb73d01d 100644 --- a/packages/ui/src/Atoms/Icons/TaskStateIcon.tsx +++ b/packages/ui/src/Atoms/Icons/TaskStateIcon.tsx @@ -12,19 +12,21 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { IconCircleCheck } from "./IconCircleCheck"; import { IconCircleProgress } from "./IconCircleProgress"; import { IconRadioUnchecked } from "./IconRadioUnchecked"; type Props = { - state: "TODO" | "DONE"; + state: "TODO" | "IN_PROGRESS" | "DONE"; }; export function TaskStateIcon({ state }: Props) { - return state === "TODO" - ? ( - - ) - : ( - - ); + switch (state) { + case "TODO": + return ; + case "IN_PROGRESS": + return ; + case "DONE": + return ; + } } diff --git a/pkg/coredata/migrations/20260402T120000Z.sql b/pkg/coredata/migrations/20260402T120000Z.sql new file mode 100644 index 000000000..221bf8560 --- /dev/null +++ b/pkg/coredata/migrations/20260402T120000Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2025-2026 Probo Inc . +-- +-- 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'; diff --git a/pkg/coredata/task_state.go b/pkg/coredata/task_state.go index 1f32eec3b..b5e7004ef 100644 --- a/pkg/coredata/task_state.go +++ b/pkg/coredata/task_state.go @@ -20,17 +20,19 @@ import ( ) type ( - TaskState uint8 + TaskState string ) const ( - TaskStateTodo TaskState = iota - TaskStateDone + TaskStateTodo TaskState = "TODO" + TaskStateInProgress TaskState = "IN_PROGRESS" + TaskStateDone TaskState = "DONE" ) func TaskStates() []TaskState { return []TaskState{ TaskStateTodo, + TaskStateInProgress, TaskStateDone, } } @@ -40,13 +42,11 @@ func (ts TaskState) MarshalText() ([]byte, error) { } func (ts *TaskState) UnmarshalText(data []byte) error { - val := string(data) + val := TaskState(data) switch val { - case TaskStateTodo.String(): - *ts = TaskStateTodo - case TaskStateDone.String(): - *ts = TaskStateDone + case TaskStateTodo, TaskStateInProgress, TaskStateDone: + *ts = val default: return fmt.Errorf("invalid TaskState value: %q", val) } @@ -55,16 +55,7 @@ func (ts *TaskState) UnmarshalText(data []byte) error { } func (ts TaskState) String() string { - var val string - - switch ts { - case TaskStateTodo: - val = "TODO" - case TaskStateDone: - val = "DONE" - } - - return val + return string(ts) } func (ts *TaskState) Scan(value any) error { diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 9c43c08d0..a93db132c 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -66,6 +66,7 @@ enum MeasureState enum TaskState @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskState") { 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") } diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 203f793f4..54c37a4af 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -4615,6 +4615,7 @@ components: type: string enum: - TODO + - IN_PROGRESS - DONE go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskState