fix: move categories in task planenr
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-28 17:02:14 +02:00
parent 3f72f152c7
commit 3dc5ed8733

View file

@ -76,6 +76,24 @@ function moveTask(tasks, fromTaskId, toTaskId, placement = "before") {
return nextTasks; 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") { function moveCategory(categoryOrder, fromCategory, toCategory, placement = "before") {
const index = categoryOrder.indexOf(fromCategory); const index = categoryOrder.indexOf(fromCategory);
const targetIndex = categoryOrder.indexOf(toCategory); const targetIndex = categoryOrder.indexOf(toCategory);
@ -164,6 +182,7 @@ function getTaskCategoryGroups(tasks, parentMap, parentId, sortMode, categoryOrd
const siblings = getTasksForParent(tasks, parentMap, parentId, sortMode); const siblings = getTasksForParent(tasks, parentMap, parentId, sortMode);
if (parentId) { if (parentId) {
return { return {
entries: siblings.map((task) => ({ type: "task", task })),
uncategorized: siblings, uncategorized: siblings,
categories: [] categories: []
}; };
@ -177,28 +196,51 @@ function getTaskCategoryGroups(tasks, parentMap, parentId, sortMode, categoryOrd
...categoryOrder.filter((category) => categories.includes(category)), ...categoryOrder.filter((category) => categories.includes(category)),
...categories.filter((category) => !categoryOrder.includes(category)) ...categories.filter((category) => !categoryOrder.includes(category))
]; ];
return { const uncategorized = siblings.filter((task) => !getTaskCategoryForParent(task, tasks, parentMap, parentId));
uncategorized: siblings.filter((task) => !getTaskCategoryForParent(task, tasks, parentMap, parentId)), const categoryGroups = orderedCategories.map((category) => ({
categories: orderedCategories.map((category) => ({
category, category,
tasks: siblings.filter((task) => getTaskCategoryForParent(task, tasks, parentMap, parentId) === 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) { function getCategorySectionId(parentId, category) {
return `${parentId || ""}\n${category}`; return `category\n${parentId || ""}\n${category}`;
} }
function parseCategorySectionId(id) { function parseCategorySectionId(id) {
const separatorIndex = id.indexOf("\n"); const parts = String(id || "").split("\n");
if (separatorIndex < 0) return { parentId: "", category: id }; if (parts[0] !== "category") return { parentId: "", category: "" };
return { return {
parentId: id.slice(0, separatorIndex), parentId: parts[1] || "",
category: id.slice(separatorIndex + 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 }) { export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] })); const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
const textContent = context.moduleText?.taskPlanner || {}; const textContent = context.moduleText?.taskPlanner || {};
@ -216,31 +258,63 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
dropTarget, dropTarget,
startDrag startDrag
} = usePointerReorder({ } = usePointerReorder({
targetSelector: ".task-planner-item", targetSelector: ".task-planner-item, .task-planner-category-section",
getTargetId: (target) => target.dataset.taskId, getTargetId: (target) => target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""),
canDropOn: (target, draggingTaskId) => { canDropOn: (target, draggingTaskId) => {
const draggingTask = data.tasks.find((task) => task.id === 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" return data.sortMode === "manual"
&& target.dataset.parentId === (parentMap.get(draggingTaskId) || "") && 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 { const {
draggingId: draggingCategoryId, draggingId: draggingCategoryId,
dropTarget: categoryDropTarget, dropTarget: categoryDropTarget,
startDrag: startCategoryDrag startDrag: startCategoryDrag
} = usePointerReorder({ } = usePointerReorder({
targetSelector: ".task-planner-category-section", targetSelector: ".task-planner-category-section, .task-planner-item",
getTargetId: (target) => getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), getTargetId: (target) => target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""),
canDropOn: (target, draggingIdValue) => { canDropOn: (target, draggingIdValue) => {
const draggingCategory = parseCategorySectionId(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) => { onMove: (fromId, toId, placement) => {
const fromCategory = parseCategorySectionId(fromId).category; const fromCategory = parseCategorySectionId(fromId).category;
const toCategory = parseCategorySectionId(toId).category; const movingIds = getRootTaskIdsForCategory(data.tasks, parentMap, fromCategory);
save({ ...data, categoryOrder: moveCategory(data.categoryOrder, fromCategory, toCategory, placement) }); 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} parentId={parentId}
draggingId={draggingId} draggingId={draggingId}
dropTarget={dropTarget} dropTarget={dropTarget}
categoryDropTarget={categoryDropTarget}
textContent={textContent} textContent={textContent}
descriptionOpen={openDescriptions.has(task.id)} descriptionOpen={openDescriptions.has(task.id)}
settingsOpen={openTaskSettings.has(task.id)} settingsOpen={openTaskSettings.has(task.id)}
@ -548,9 +623,7 @@ function TaskPlannerBranch({
); );
} }
return [ function renderCategory(group) {
...groupedTasks.uncategorized.filter((task) => !visited.has(task.id)).map(renderTask),
...groupedTasks.categories.map((group) => {
const groupTasks = group.tasks.filter((task) => !visited.has(task.id)); const groupTasks = group.tasks.filter((task) => !visited.has(task.id));
if (!groupTasks.length) return null; if (!groupTasks.length) return null;
const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap); const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
@ -558,8 +631,8 @@ function TaskPlannerBranch({
const sectionClassName = [ const sectionClassName = [
"task-planner-category-section", "task-planner-category-section",
draggingCategoryId === categoryId ? "is-dragging" : "", draggingCategoryId === categoryId ? "is-dragging" : "",
categoryDropTarget.id === categoryId ? "is-drop-target" : "", categoryDropTarget.id === categoryId || dropTarget.id === categoryId ? "is-drop-target" : "",
categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after" ? "drop-after" : "" (categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after") || (dropTarget.id === categoryId && dropTarget.placement === "after") ? "drop-after" : ""
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
return ( return (
<li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} data-parent-id={parentId} data-category={group.category}> <li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} data-parent-id={parentId} data-category={group.category}>
@ -583,8 +656,12 @@ function TaskPlannerBranch({
</ul> </ul>
</li> </li>
); );
}) }
].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({ function TaskPlannerItem({
@ -595,6 +672,7 @@ function TaskPlannerItem({
parentId, parentId,
draggingId, draggingId,
dropTarget, dropTarget,
categoryDropTarget,
textContent, textContent,
descriptionOpen, descriptionOpen,
settingsOpen, settingsOpen,
@ -611,6 +689,8 @@ function TaskPlannerItem({
onSetTaskCategory, onSetTaskCategory,
children children
}) { }) {
const [titleDraft, setTitleDraft] = useState(task.title);
const [descriptionDraft, setDescriptionDraft] = useState(task.description);
const parentTask = parentId ? getTaskById(tasks, parentId) : null; const parentTask = parentId ? getTaskById(tasks, parentId) : null;
const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : ""; const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : "";
const categoryValue = parentTask ? inheritedCategory : task.category || ""; const categoryValue = parentTask ? inheritedCategory : task.category || "";
@ -625,8 +705,8 @@ function TaskPlannerItem({
task.checked ? "is-complete" : "", task.checked ? "is-complete" : "",
missingPrerequisites.length ? "has-missing-prerequisite" : "", missingPrerequisites.length ? "has-missing-prerequisite" : "",
draggingId === task.id ? "is-dragging" : "", draggingId === task.id ? "is-dragging" : "",
dropTarget.id === task.id ? "is-drop-target" : "", dropTarget.id === task.id || categoryDropTarget.id === task.id ? "is-drop-target" : "",
dropTarget.id === task.id && dropTarget.placement === "after" ? "drop-after" : "" (dropTarget.id === task.id && dropTarget.placement === "after") || (categoryDropTarget.id === task.id && categoryDropTarget.placement === "after") ? "drop-after" : ""
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
function updateChecked(event) { function updateChecked(event) {
@ -643,6 +723,26 @@ function TaskPlannerItem({
setCategoryDraft(categoryValue); setCategoryDraft(categoryValue);
}, [categoryValue, task.id]); }, [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() { function commitCategoryDraft() {
if (categoryEditable && categoryDraft.trim() !== (task.category || "")) onSetTaskCategory(task.id, categoryDraft); if (categoryEditable && categoryDraft.trim() !== (task.category || "")) onSetTaskCategory(task.id, categoryDraft);
} }
@ -668,9 +768,12 @@ function TaskPlannerItem({
/> />
<input <input
className="tool-split-entry-label task-planner-title-input" className="tool-split-entry-label task-planner-title-input"
value={task.title} value={titleDraft}
onChange={(event) => onUpdateTask(task.id, (current) => ({ ...current, title: event.target.value }))} onChange={(event) => setTitleDraft(event.target.value)}
onBlur={(event) => onUpdateTask(task.id, (current) => ({ ...current, title: event.target.value.trim() || current.title }))} onBlur={commitTitleDraft}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
aria-label={textContent.titleLabel || "Titre"} aria-label={textContent.titleLabel || "Titre"}
/> />
<select <select
@ -738,8 +841,9 @@ function TaskPlannerItem({
<span>{textContent.descriptionLabel || "Description"}</span> <span>{textContent.descriptionLabel || "Description"}</span>
<textarea <textarea
className="task-planner-description" className="task-planner-description"
value={task.description} value={descriptionDraft}
onChange={(event) => onUpdateTask(task.id, (current) => ({ ...current, description: event.target.value }))} onChange={(event) => setDescriptionDraft(event.target.value)}
onBlur={commitDescriptionDraft}
placeholder={textContent.descriptionPlaceholder || "Description"} placeholder={textContent.descriptionPlaceholder || "Description"}
aria-label={`${textContent.descriptionLabel || "Description"} ${task.title}`} aria-label={`${textContent.descriptionLabel || "Description"} ${task.title}`}
/> />