diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx index bdab6ff..3bcde23 100644 --- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx +++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx @@ -76,6 +76,24 @@ function moveTask(tasks, fromTaskId, toTaskId, placement = "before") { return nextTasks; } +function moveTaskGroup(tasks, fromTaskIds, toTaskId, placement = "before") { + const movingIds = new Set(fromTaskIds); + if (!movingIds.size || movingIds.has(toTaskId)) return tasks; + const targetIndex = tasks.findIndex((task) => task.id === toTaskId); + if (targetIndex < 0) return tasks; + const movingTasks = tasks.filter((task) => movingIds.has(task.id)); + const remainingTasks = tasks.filter((task) => !movingIds.has(task.id)); + const nextTargetIndex = remainingTasks.findIndex((task) => task.id === toTaskId); + remainingTasks.splice(placement === "after" ? nextTargetIndex + 1 : nextTargetIndex, 0, ...movingTasks); + return remainingTasks; +} + +function getBoundaryTaskId(tasks, taskIds, placement) { + const ids = new Set(taskIds); + const orderedTasks = tasks.filter((task) => ids.has(task.id)); + return placement === "after" ? orderedTasks.at(-1)?.id || "" : orderedTasks[0]?.id || ""; +} + function moveCategory(categoryOrder, fromCategory, toCategory, placement = "before") { const index = categoryOrder.indexOf(fromCategory); const targetIndex = categoryOrder.indexOf(toCategory); @@ -164,6 +182,7 @@ function getTaskCategoryGroups(tasks, parentMap, parentId, sortMode, categoryOrd const siblings = getTasksForParent(tasks, parentMap, parentId, sortMode); if (parentId) { return { + entries: siblings.map((task) => ({ type: "task", task })), uncategorized: siblings, categories: [] }; @@ -177,28 +196,51 @@ function getTaskCategoryGroups(tasks, parentMap, parentId, sortMode, categoryOrd ...categoryOrder.filter((category) => categories.includes(category)), ...categories.filter((category) => !categoryOrder.includes(category)) ]; - return { - uncategorized: siblings.filter((task) => !getTaskCategoryForParent(task, tasks, parentMap, parentId)), - categories: orderedCategories.map((category) => ({ + const uncategorized = siblings.filter((task) => !getTaskCategoryForParent(task, tasks, parentMap, parentId)); + const categoryGroups = orderedCategories.map((category) => ({ category, tasks: siblings.filter((task) => getTaskCategoryForParent(task, tasks, parentMap, parentId) === category) - })).filter((group) => group.tasks.length) + })).filter((group) => group.tasks.length); + if (sortMode !== "manual") return { entries: [...uncategorized.map((task) => ({ type: "task", task })), ...categoryGroups.map((group) => ({ type: "category", group }))], uncategorized, categories: categoryGroups }; + + const renderedCategories = new Set(); + const entries = siblings.map((task) => { + const category = getTaskCategoryForParent(task, tasks, parentMap, parentId); + if (!category) return { type: "task", task }; + if (renderedCategories.has(category)) return null; + renderedCategories.add(category); + return { type: "category", group: categoryGroups.find((group) => group.category === category) }; + }).filter(Boolean); + return { + entries, + uncategorized, + categories: categoryGroups }; } function getCategorySectionId(parentId, category) { - return `${parentId || ""}\n${category}`; + return `category\n${parentId || ""}\n${category}`; } function parseCategorySectionId(id) { - const separatorIndex = id.indexOf("\n"); - if (separatorIndex < 0) return { parentId: "", category: id }; + const parts = String(id || "").split("\n"); + if (parts[0] !== "category") return { parentId: "", category: "" }; return { - parentId: id.slice(0, separatorIndex), - category: id.slice(separatorIndex + 1) + parentId: parts[1] || "", + category: parts.slice(2).join("\n") }; } +function isCategorySectionId(id) { + return String(id || "").startsWith("category\n"); +} + +function getRootTaskIdsForCategory(tasks, parentMap, category) { + return tasks + .filter((task) => !parentMap.get(task.id) && getTaskCategory(task) === category) + .map((task) => task.id); +} + export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] })); const textContent = context.moduleText?.taskPlanner || {}; @@ -216,31 +258,63 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { dropTarget, startDrag } = usePointerReorder({ - targetSelector: ".task-planner-item", - getTargetId: (target) => target.dataset.taskId, + targetSelector: ".task-planner-item, .task-planner-category-section", + getTargetId: (target) => target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), canDropOn: (target, draggingTaskId) => { const draggingTask = data.tasks.find((task) => task.id === draggingTaskId); + if (!draggingTask || data.sortMode !== "manual") return false; + if (target.classList.contains("task-planner-category-section")) { + return !parentMap.get(draggingTaskId) && !getTaskCategory(draggingTask) && !target.dataset.parentId && Boolean(target.dataset.category); + } return data.sortMode === "manual" && target.dataset.parentId === (parentMap.get(draggingTaskId) || "") - && target.dataset.category === getTaskCategoryDataset(draggingTask, data.tasks, parentMap); + && ( + target.dataset.category === getTaskCategoryDataset(draggingTask, data.tasks, parentMap) + || (!parentMap.get(draggingTaskId) && !getTaskCategory(draggingTask) && Boolean(target.dataset.category)) + ); }, - onMove: (fromTaskId, toTaskId, placement) => save({ ...data, tasks: moveTask(data.tasks, fromTaskId, toTaskId, placement) }) + onMove: (fromTaskId, toId, placement) => { + if (isCategorySectionId(toId)) { + const targetCategory = parseCategorySectionId(toId).category; + const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, targetCategory); + const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, placement); + if (boundaryTaskId) save({ ...data, tasks: moveTask(data.tasks, fromTaskId, boundaryTaskId, placement) }); + return; + } + save({ ...data, tasks: moveTask(data.tasks, fromTaskId, toId, placement) }); + } }); const { draggingId: draggingCategoryId, dropTarget: categoryDropTarget, startDrag: startCategoryDrag } = usePointerReorder({ - targetSelector: ".task-planner-category-section", - getTargetId: (target) => getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), + targetSelector: ".task-planner-category-section, .task-planner-item", + getTargetId: (target) => target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), canDropOn: (target, draggingIdValue) => { const draggingCategory = parseCategorySectionId(draggingIdValue); - return target.dataset.parentId === draggingCategory.parentId && Boolean(target.dataset.category); + if (!draggingCategory.category || target.dataset.parentId !== draggingCategory.parentId) return false; + if (target.classList.contains("task-planner-category-section")) return Boolean(target.dataset.category); + return !target.dataset.category; }, onMove: (fromId, toId, placement) => { const fromCategory = parseCategorySectionId(fromId).category; - const toCategory = parseCategorySectionId(toId).category; - save({ ...data, categoryOrder: moveCategory(data.categoryOrder, fromCategory, toCategory, placement) }); + const movingIds = getRootTaskIdsForCategory(data.tasks, parentMap, fromCategory); + if (!movingIds.length) return; + if (isCategorySectionId(toId)) { + const toCategory = parseCategorySectionId(toId).category; + const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, toCategory); + const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, placement); + if (boundaryTaskId) { + save({ + ...data, + tasks: moveTaskGroup(data.tasks, movingIds, boundaryTaskId, placement), + categoryOrder: moveCategory(data.categoryOrder, fromCategory, toCategory, placement) + }); + } + return; + } + save({ ...data, tasks: moveTaskGroup(data.tasks, movingIds, toId, placement) }); } }); @@ -501,6 +575,7 @@ function TaskPlannerBranch({ parentId={parentId} draggingId={draggingId} dropTarget={dropTarget} + categoryDropTarget={categoryDropTarget} textContent={textContent} descriptionOpen={openDescriptions.has(task.id)} settingsOpen={openTaskSettings.has(task.id)} @@ -548,9 +623,7 @@ function TaskPlannerBranch({ ); } - return [ - ...groupedTasks.uncategorized.filter((task) => !visited.has(task.id)).map(renderTask), - ...groupedTasks.categories.map((group) => { + function renderCategory(group) { const groupTasks = group.tasks.filter((task) => !visited.has(task.id)); if (!groupTasks.length) return null; const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap); @@ -558,8 +631,8 @@ function TaskPlannerBranch({ const sectionClassName = [ "task-planner-category-section", draggingCategoryId === categoryId ? "is-dragging" : "", - categoryDropTarget.id === categoryId ? "is-drop-target" : "", - categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after" ? "drop-after" : "" + categoryDropTarget.id === categoryId || dropTarget.id === categoryId ? "is-drop-target" : "", + (categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after") || (dropTarget.id === categoryId && dropTarget.placement === "after") ? "drop-after" : "" ].filter(Boolean).join(" "); return (
  • @@ -583,8 +656,12 @@ function TaskPlannerBranch({
  • ); - }) - ].filter(Boolean); + } + + return groupedTasks.entries.map((entry) => { + if (entry.type === "task") return visited.has(entry.task.id) ? null : renderTask(entry.task); + return renderCategory(entry.group); + }).filter(Boolean); } function TaskPlannerItem({ @@ -595,6 +672,7 @@ function TaskPlannerItem({ parentId, draggingId, dropTarget, + categoryDropTarget, textContent, descriptionOpen, settingsOpen, @@ -611,6 +689,8 @@ function TaskPlannerItem({ onSetTaskCategory, children }) { + const [titleDraft, setTitleDraft] = useState(task.title); + const [descriptionDraft, setDescriptionDraft] = useState(task.description); const parentTask = parentId ? getTaskById(tasks, parentId) : null; const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : ""; const categoryValue = parentTask ? inheritedCategory : task.category || ""; @@ -625,8 +705,8 @@ function TaskPlannerItem({ task.checked ? "is-complete" : "", missingPrerequisites.length ? "has-missing-prerequisite" : "", draggingId === task.id ? "is-dragging" : "", - dropTarget.id === task.id ? "is-drop-target" : "", - dropTarget.id === task.id && dropTarget.placement === "after" ? "drop-after" : "" + dropTarget.id === task.id || categoryDropTarget.id === task.id ? "is-drop-target" : "", + (dropTarget.id === task.id && dropTarget.placement === "after") || (categoryDropTarget.id === task.id && categoryDropTarget.placement === "after") ? "drop-after" : "" ].filter(Boolean).join(" "); function updateChecked(event) { @@ -643,6 +723,26 @@ function TaskPlannerItem({ setCategoryDraft(categoryValue); }, [categoryValue, task.id]); + useEffect(() => { + setTitleDraft(task.title); + }, [task.id, task.title]); + + useEffect(() => { + setDescriptionDraft(task.description); + }, [task.description, task.id]); + + function commitTitleDraft() { + const title = titleDraft.trim() || task.title; + if (title !== task.title) onUpdateTask(task.id, (current) => ({ ...current, title })); + else if (titleDraft !== title) setTitleDraft(title); + } + + function commitDescriptionDraft() { + const description = descriptionDraft.trim(); + if (description !== task.description) onUpdateTask(task.id, (current) => ({ ...current, description })); + else if (descriptionDraft !== description) setDescriptionDraft(description); + } + function commitCategoryDraft() { if (categoryEditable && categoryDraft.trim() !== (task.category || "")) onSetTaskCategory(task.id, categoryDraft); } @@ -668,9 +768,12 @@ function TaskPlannerItem({ /> onUpdateTask(task.id, (current) => ({ ...current, title: event.target.value }))} - onBlur={(event) => onUpdateTask(task.id, (current) => ({ ...current, title: event.target.value.trim() || current.title }))} + value={titleDraft} + onChange={(event) => setTitleDraft(event.target.value)} + onBlur={commitTitleDraft} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} aria-label={textContent.titleLabel || "Titre"} />