199 lines
8.3 KiB
JavaScript
199 lines
8.3 KiB
JavaScript
// Rôle : rend les branches récursives, catégories et zones de drop du task planner.
|
|
import { Icon } from "../../../components/Icon.jsx";
|
|
import { TaskPlannerItem } from "./TaskPlannerItem.jsx";
|
|
import {
|
|
countCompletedTaskTree,
|
|
countTaskTree,
|
|
getTaskTypeLabel,
|
|
getTasksForParent,
|
|
getTaskCategoryGroups,
|
|
isTaskTreeComplete
|
|
} from "./taskPlannerUtils.js";
|
|
|
|
export function TaskPlannerBranch({
|
|
parentId,
|
|
parentMap,
|
|
tasks,
|
|
data,
|
|
nowMs,
|
|
reorder,
|
|
textContent,
|
|
openDescriptions,
|
|
openTaskSettings,
|
|
prerequisiteNoticeTaskIds,
|
|
onToggleDescription,
|
|
onToggleSettings,
|
|
onPrerequisiteNotice,
|
|
onUpdateTask,
|
|
onDeleteTask,
|
|
onAddLinkTarget,
|
|
onRemoveLinkTarget,
|
|
onSetPrerequisite,
|
|
onSetTaskCategory,
|
|
onToggleCategoryCollapsed,
|
|
visited = new Set()
|
|
}) {
|
|
const groupedTasks = getTaskCategoryGroups(tasks, parentMap, parentId, data.categoryOrder);
|
|
const children = [...groupedTasks.uncategorized, ...groupedTasks.categories.flatMap((group) => group.tasks)].filter((task) => !visited.has(task.id));
|
|
if (!children.length) return null;
|
|
const nextVisited = new Set([...visited, ...children.map((task) => task.id)]);
|
|
const showCategoryBoundaryDropZones = !parentId && reorder.shouldShowGroupBoundaries({ parentId });
|
|
|
|
function hasVisibleDescendants(taskId, seen = new Set()) {
|
|
if (seen.has(taskId)) return false;
|
|
seen.add(taskId);
|
|
return getTasksForParent(tasks, parentMap, taskId).some((child) => !child.checked || hasVisibleDescendants(child.id, seen));
|
|
}
|
|
|
|
function renderTask(task) {
|
|
const nestedChildren = getTasksForParent(tasks, parentMap, task.id).filter((child) => !nextVisited.has(child.id));
|
|
const nestedBranch = nestedChildren.length ? (
|
|
<TaskPlannerBranch
|
|
parentId={task.id}
|
|
parentMap={parentMap}
|
|
tasks={tasks}
|
|
data={data}
|
|
nowMs={nowMs}
|
|
reorder={reorder}
|
|
textContent={textContent}
|
|
openDescriptions={openDescriptions}
|
|
openTaskSettings={openTaskSettings}
|
|
prerequisiteNoticeTaskIds={prerequisiteNoticeTaskIds}
|
|
onToggleDescription={onToggleDescription}
|
|
onToggleSettings={onToggleSettings}
|
|
onPrerequisiteNotice={onPrerequisiteNotice}
|
|
onUpdateTask={onUpdateTask}
|
|
onDeleteTask={onDeleteTask}
|
|
onAddLinkTarget={onAddLinkTarget}
|
|
onRemoveLinkTarget={onRemoveLinkTarget}
|
|
onSetPrerequisite={onSetPrerequisite}
|
|
onSetTaskCategory={onSetTaskCategory}
|
|
onToggleCategoryCollapsed={onToggleCategoryCollapsed}
|
|
visited={nextVisited}
|
|
/>
|
|
) : null;
|
|
if (data.hideCompleted && task.checked) {
|
|
if (!hasVisibleDescendants(task.id)) return null;
|
|
return (
|
|
<li className="task-planner-hidden-parent" key={`${task.id}:visible-children`}>
|
|
<div className="task-planner-hidden-parent-line">
|
|
<input
|
|
type="checkbox"
|
|
checked={task.checked}
|
|
onChange={(event) => onUpdateTask(task.id, (current) => ({
|
|
...current,
|
|
checked: event.target.checked,
|
|
checkedAt: event.target.checked ? Date.now() : 0
|
|
}))}
|
|
aria-label={`${textContent.uncheckTitle || "Marquer non effectué"} ${task.title}`}
|
|
/>
|
|
<span title={task.title}>{task.title}</span>
|
|
<small>{getTaskTypeLabel(task.type, textContent)}</small>
|
|
</div>
|
|
<ul className="task-planner-list task-planner-child-list">{nestedBranch}</ul>
|
|
</li>
|
|
);
|
|
}
|
|
return (
|
|
<TaskPlannerItem
|
|
key={task.id}
|
|
task={task}
|
|
tasks={tasks}
|
|
data={data}
|
|
nowMs={nowMs}
|
|
parentMap={parentMap}
|
|
parentId={parentId}
|
|
reorder={reorder}
|
|
textContent={textContent}
|
|
descriptionOpen={openDescriptions.has(task.id)}
|
|
settingsOpen={openTaskSettings.has(task.id)}
|
|
prerequisiteNoticeVisible={prerequisiteNoticeTaskIds.has(task.id)}
|
|
onToggleDescription={() => onToggleDescription(task.id)}
|
|
onToggleSettings={() => onToggleSettings(task.id)}
|
|
onPrerequisiteNotice={onPrerequisiteNotice}
|
|
onUpdateTask={onUpdateTask}
|
|
onDeleteTask={onDeleteTask}
|
|
onAddLinkTarget={onAddLinkTarget}
|
|
onRemoveLinkTarget={onRemoveLinkTarget}
|
|
onSetPrerequisite={onSetPrerequisite}
|
|
onSetTaskCategory={onSetTaskCategory}
|
|
>
|
|
{nestedBranch}
|
|
</TaskPlannerItem>
|
|
);
|
|
}
|
|
|
|
function renderCategory(group) {
|
|
const groupTasks = group.tasks.filter((task) => !visited.has(task.id));
|
|
if (!groupTasks.length) return null;
|
|
if (data.hideCompleted && isTaskTreeComplete(groupTasks.map((task) => task.id), tasks, parentMap)) return null;
|
|
const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
|
|
const groupCompletedCount = countCompletedTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
|
|
const isCollapsed = data.collapsedCategories.includes(group.category);
|
|
const sectionClassName = [
|
|
"task-planner-category-section",
|
|
groupCompletedCount === groupCount ? "is-complete" : "",
|
|
isCollapsed ? "is-collapsed" : "",
|
|
reorder.isGroupDragging(group.category, parentId) ? "is-dragging" : "",
|
|
reorder.isGroupDropTarget(group.category, parentId) ? "is-drop-target" : "",
|
|
reorder.getDropPlacement("group", group.category, parentId) === "after" ? "drop-after" : ""
|
|
].filter(Boolean).join(" ");
|
|
const beforeDropZone = showCategoryBoundaryDropZones ? (
|
|
<li
|
|
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "before" }) ? "is-drop-target" : ""}`}
|
|
key={`${parentId || "root"}:${group.category}:before-drop`}
|
|
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "before" })}
|
|
/>
|
|
) : null;
|
|
const afterDropZone = showCategoryBoundaryDropZones ? (
|
|
<li
|
|
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "after" }) ? "is-drop-target" : ""}`}
|
|
key={`${parentId || "root"}:${group.category}:after-drop`}
|
|
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "after" })}
|
|
/>
|
|
) : null;
|
|
return [
|
|
beforeDropZone,
|
|
<li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} {...reorder.getGroupProps({ groupId: group.category, parentId })}>
|
|
<div className="checklist-section-header task-planner-category-header">
|
|
<div className="task-planner-category-title">
|
|
<button
|
|
className="task-planner-category-drag-handle"
|
|
type="button"
|
|
onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: group.category, parentId })}
|
|
aria-label={`${textContent.categoryReorderTitle || "Déplacer la catégorie"} ${group.category}`}
|
|
title={textContent.categoryReorderTitle || "Déplacer la catégorie"}
|
|
>
|
|
<Icon name="drag" />
|
|
</button>
|
|
<h3>{group.category}</h3>
|
|
</div>
|
|
<div className="task-planner-category-actions">
|
|
<span>{groupCompletedCount} / {groupCount}</span>
|
|
<button
|
|
className="checklist-section-collapse-button"
|
|
type="button"
|
|
onClick={() => onToggleCategoryCollapsed(group.category)}
|
|
aria-expanded={!isCollapsed}
|
|
title={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
|
|
aria-label={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
|
|
>
|
|
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{!isCollapsed && (
|
|
<ul className="task-planner-list">
|
|
{groupTasks.map(renderTask).filter(Boolean)}
|
|
</ul>
|
|
)}
|
|
</li>,
|
|
afterDropZone
|
|
];
|
|
}
|
|
|
|
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);
|
|
}
|