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:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ func TestTask_StateEnum(t *testing.T) {
|
|||||||
|
|
||||||
states := []string{
|
states := []string{
|
||||||
"TODO",
|
"TODO",
|
||||||
|
"IN_PROGRESS",
|
||||||
"DONE",
|
"DONE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
pkg/coredata/migrations/20260402T120000Z.sql
Normal file
15
pkg/coredata/migrations/20260402T120000Z.sql
Normal 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';
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user