Refactor task planner item panels
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 22:03:45 +02:00
parent 126c673a2d
commit a796edcb02
3 changed files with 274 additions and 207 deletions

View file

@ -161,6 +161,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "utf8");
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
const taskPlannerItemPanels = await readFile("website/src/features/toolboxes/modules/TaskPlannerItemPanels.jsx", "utf8");
const taskPlannerBundle = `${taskPlannerItems}\n${taskPlannerItemPanels}`;
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
const goalTreeModule = await readFile("website/src/features/toolboxes/modules/GoalTreeModule.jsx", "utf8");
const goalTreeParts = await readFile("website/src/features/toolboxes/modules/GoalTreeParts.jsx", "utf8");
@ -365,11 +367,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(taskPlannerModule, /reorderFeatures/);
assert.match(taskPlannerItems, /export function TaskPlannerBranch/);
assert.match(taskPlannerItems, /function TaskPlannerItem/);
assert.match(taskPlannerItems, /task-planner-warning/);
assert.match(taskPlannerItems, /useInlineEdit/);
assert.match(taskPlannerItems, /commitOnEnter: false/);
assert.match(taskPlannerItems, /categoryLabel/);
assert.match(taskPlannerItems, /prerequisite/);
assert.match(taskPlannerBundle, /task-planner-warning/);
assert.match(taskPlannerBundle, /useInlineEdit/);
assert.match(taskPlannerBundle, /commitOnEnter: false/);
assert.match(taskPlannerBundle, /categoryLabel/);
assert.match(taskPlannerBundle, /prerequisite/);
assert.match(taskPlannerUtils, /export function getTaskParentMap/);
assert.match(taskPlannerUtils, /export function applyDueResets/);
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);

View file

@ -0,0 +1,246 @@
// Rôle : regroupe les panneaux secondaires d'une tâche du task planner.
import { Icon } from "../../../components/Icon.jsx";
import { InlineNotice } from "../../../components/AppOverlays.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import {
WEEK_DAYS,
getEffectiveTaskCategory,
getTaskById,
getTaskTitle
} from "./taskPlannerUtils.js";
export function MissingPrerequisitesTooltip({ missingPrerequisites, tasks, textContent }) {
return (
<Tooltip
className="task-planner-warning"
content={(
<span className="task-planner-prerequisite-tooltip">
<strong>{textContent.missingPrerequisiteTitle || "Pré requis non effectué"}</strong>
<span>
{missingPrerequisites.map((relation) => (
<span key={relation.id}>{getTaskTitle(tasks, relation.toTaskId)}</span>
))}
</span>
</span>
)}
position="top-start"
>
{textContent.missingPrerequisiteBadge || "Pré requis"}
</Tooltip>
);
}
export function TaskPlannerItemDetails({
task,
tasks,
data,
parentMap,
parentId,
textContent,
descriptionOpen,
settingsOpen,
noticeMessage,
relations,
relationTargets,
onUpdateTask,
onAddLinkTarget,
onRemoveLinkTarget,
onSetPrerequisite,
onSetTaskCategory
}) {
return (
<>
<InlineNotice className="task-planner-notice" message={noticeMessage} />
{descriptionOpen && (
<TaskDescriptionPanel
task={task}
tasks={tasks}
parentMap={parentMap}
parentId={parentId}
textContent={textContent}
onUpdateTask={onUpdateTask}
onSetTaskCategory={onSetTaskCategory}
/>
)}
{settingsOpen && (
<div className="task-planner-task-settings">
<TaskResetSettings task={task} data={data} textContent={textContent} onUpdateTask={onUpdateTask} />
<LinksEditor
label={textContent.linksLabel || "Parent"}
relations={relations}
task={task}
tasks={tasks}
parentMap={parentMap}
relationTargets={relationTargets}
emptyLabel={textContent.emptyLinks || "Aucun parent"}
addLabel={textContent.addLinkLabel || "Définir un parent"}
removeLabel={textContent.removeRelationTitle || "Retirer"}
prerequisiteLabel={textContent.prerequisiteCheckboxLabel || "Pré requis"}
onAddLinkTarget={onAddLinkTarget}
onRemoveLinkTarget={onRemoveLinkTarget}
onSetPrerequisite={onSetPrerequisite}
/>
</div>
)}
</>
);
}
function TaskDescriptionPanel({ task, tasks, parentMap, parentId, textContent, onUpdateTask, onSetTaskCategory }) {
const parentTask = parentId ? getTaskById(tasks, parentId) : null;
const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : "";
const categoryValue = parentTask ? inheritedCategory : task.category || "";
const categoryEditable = !parentTask;
const categoryEdit = useInlineEdit({
value: categoryValue,
onCommit: (category) => {
if (categoryEditable) onSetTaskCategory(task.id, category);
}
});
const descriptionEdit = useInlineEdit({
value: task.description,
onCommit: (description) => onUpdateTask(task.id, (current) => ({ ...current, description })),
commitOnEnter: false
});
return (
<div className="task-planner-description-panel">
<label className={`task-planner-category-field ${categoryEditable ? "" : "is-disabled"}`}>
<span>{textContent.categoryLabel || "Catégorie"}</span>
<input
{...categoryEdit.getInputProps({
disabled: !categoryEditable,
placeholder: textContent.categoryPlaceholder || "Nom de catégorie",
"aria-label": `${textContent.categoryLabel || "Catégorie"} ${task.title}`
})}
/>
</label>
<label className="task-planner-description-field">
<span>{textContent.descriptionLabel || "Description"}</span>
<textarea
{...descriptionEdit.getInputProps({
className: "task-planner-description",
placeholder: textContent.descriptionPlaceholder || "Description",
"aria-label": `${textContent.descriptionLabel || "Description"} ${task.title}`
})}
/>
</label>
</div>
);
}
function TaskResetSettings({ task, data, textContent, onUpdateTask }) {
if (task.type === "daily") {
const hasOverride = Boolean(task.dailyResetTime);
return (
<div className="task-planner-reset-override">
<span>{textContent.taskDailyResetTimeLabel || "Heure quotidienne"}</span>
<div className="task-planner-reset-control">
<input
type="time"
value={task.dailyResetTime || data.resetTime}
onChange={(event) => onUpdateTask(task.id, (current) => {
const nextTask = { ...current };
if (event.target.value && event.target.value !== data.resetTime) nextTask.dailyResetTime = event.target.value;
else delete nextTask.dailyResetTime;
return nextTask;
})}
aria-label={`${textContent.taskDailyResetTimeLabel || "Heure quotidienne"} ${task.title}`}
/>
<button
type="button"
onClick={() => onUpdateTask(task.id, (current) => {
const nextTask = { ...current };
delete nextTask.dailyResetTime;
return nextTask;
})}
disabled={!hasOverride}
title={textContent.inheritResetTime || "Utiliser l'heure globale"}
aria-label={`${textContent.inheritResetTime || "Utiliser l'heure globale"} ${task.title}`}
>
<Icon name="rubber" />
</button>
</div>
</div>
);
}
if (task.type === "weekly") {
return (
<label className="task-planner-reset-override">
<span>{textContent.taskWeeklyResetDayLabel || "Jour hebdo"}</span>
<select
value={task.weeklyResetDay ?? ""}
onChange={(event) => onUpdateTask(task.id, (current) => {
const nextTask = { ...current };
if (event.target.value === "") {
delete nextTask.weeklyResetDay;
} else {
nextTask.weeklyResetDay = Number(event.target.value);
}
return nextTask;
})}
>
<option value="">{textContent.inheritWeeklyResetDay || "Global"}</option>
{WEEK_DAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</label>
);
}
return (
<div className="task-planner-reset-override is-empty">
<span>{textContent.taskResetLabel || "Réinitialisation"}</span>
<p>{textContent.noTaskResetLabel || "Aucun reset"}</p>
</div>
);
}
function LinksEditor({ label, relations, task, tasks, parentMap, relationTargets, emptyLabel, addLabel, removeLabel, prerequisiteLabel, onAddLinkTarget, onRemoveLinkTarget, onSetPrerequisite }) {
const descendantIds = new Set();
function collectDescendants(taskId) {
tasks.filter((item) => parentMap.get(item.id) === taskId).forEach((child) => {
descendantIds.add(child.id);
collectDescendants(child.id);
});
}
collectDescendants(task.id);
const availableTargets = relationTargets.filter((target) => !descendantIds.has(target.id));
return (
<div className="task-planner-relations">
<span>{label}</span>
<select
value=""
onChange={(event) => {
onAddLinkTarget(task.id, event.target.value);
event.target.value = "";
}}
aria-label={addLabel}
>
<option value="">{addLabel}</option>
{availableTargets.map((target) => <option key={target.id} value={target.id}>{target.title}</option>)}
</select>
<div>
{relations.length ? relations.map((relation) => (
<div className="task-planner-relation-chip" key={relation.id}>
<span>{getTaskTitle(tasks, relation.toTaskId)}</span>
<label title={prerequisiteLabel}>
<input
type="checkbox"
checked={relation.prerequisite}
onChange={(event) => onSetPrerequisite(task.id, relation.toTaskId, event.target.checked)}
aria-label={`${prerequisiteLabel} ${getTaskTitle(tasks, relation.toTaskId)}`}
/>
<span>{prerequisiteLabel}</span>
</label>
<button type="button" onClick={() => onRemoveLinkTarget(task.id, relation.toTaskId)} title={removeLabel} aria-label={`${removeLabel} ${getTaskTitle(tasks, relation.toTaskId)}`}>
<Icon name="close" />
</button>
</div>
)) : <em>{emptyLabel}</em>}
</div>
</div>
);
}

View file

@ -1,18 +1,13 @@
// Rôle : rend les branches, items et panneaux secondaires du task planner.
// Rôle : rend les branches et lignes de tâches du task planner.
import { Icon } from "../../../components/Icon.jsx";
import { InlineNotice } from "../../../components/AppOverlays.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { MissingPrerequisitesTooltip, TaskPlannerItemDetails } from "./TaskPlannerItemPanels.jsx";
import {
TASK_TYPES,
WEEK_DAYS,
countCompletedTaskTree,
countTaskTree,
getEffectiveTaskCategory,
getTaskById,
getTaskCategoryDataset,
getTaskCategoryGroups,
getTaskTitle,
getTaskTypeLabel,
getTasksForParent,
isTaskResetSoon,
@ -231,10 +226,6 @@ function TaskPlannerItem({
onSetTaskCategory,
children
}) {
const parentTask = parentId ? getTaskById(tasks, parentId) : null;
const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : "";
const categoryValue = parentTask ? inheritedCategory : task.category || "";
const categoryEditable = !parentTask;
const relations = data.relations.filter((relation) => relation.fromTaskId === task.id);
const missingPrerequisites = relations.filter((relation) => relation.prerequisite && !tasks.find((item) => item.id === relation.toTaskId)?.checked);
const relationTargets = tasks.filter((item) => item.id !== task.id && !relations.some((relation) => relation.toTaskId === item.id));
@ -253,17 +244,6 @@ function TaskPlannerItem({
transform: (draft) => String(draft || "").trim() || task.title,
onCommit: (title) => onUpdateTask(task.id, (current) => ({ ...current, title }))
});
const categoryEdit = useInlineEdit({
value: categoryValue,
onCommit: (category) => {
if (categoryEditable) onSetTaskCategory(task.id, category);
}
});
const descriptionEdit = useInlineEdit({
value: task.description,
onCommit: (description) => onUpdateTask(task.id, (current) => ({ ...current, description })),
commitOnEnter: false
});
function updateChecked(event) {
const checked = event.target.checked;
@ -314,13 +294,7 @@ function TaskPlannerItem({
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select>
{missingPrerequisites.length > 0 && (
<Tooltip
className="task-planner-warning"
content={<MissingPrerequisitesTooltip missingPrerequisites={missingPrerequisites} tasks={tasks} textContent={textContent} />}
position="top-start"
>
{textContent.missingPrerequisiteBadge || "Pré requis"}
</Tooltip>
<MissingPrerequisitesTooltip missingPrerequisites={missingPrerequisites} tasks={tasks} textContent={textContent} />
)}
<div className="task-planner-actions">
<button
@ -348,180 +322,25 @@ function TaskPlannerItem({
</button>
</div>
</div>
<InlineNotice className="task-planner-notice" message={noticeMessage} />
{descriptionOpen && (
<div className="task-planner-description-panel">
<label className={`task-planner-category-field ${categoryEditable ? "" : "is-disabled"}`}>
<span>{textContent.categoryLabel || "Catégorie"}</span>
<input
{...categoryEdit.getInputProps({
disabled: !categoryEditable,
placeholder: textContent.categoryPlaceholder || "Nom de catégorie",
"aria-label": `${textContent.categoryLabel || "Catégorie"} ${task.title}`
})}
/>
</label>
<label className="task-planner-description-field">
<span>{textContent.descriptionLabel || "Description"}</span>
<textarea
{...descriptionEdit.getInputProps({
className: "task-planner-description",
placeholder: textContent.descriptionPlaceholder || "Description",
"aria-label": `${textContent.descriptionLabel || "Description"} ${task.title}`
})}
/>
</label>
</div>
)}
{settingsOpen && (
<div className="task-planner-task-settings">
<TaskResetSettings task={task} data={data} textContent={textContent} onUpdateTask={onUpdateTask} />
<LinksEditor
label={textContent.linksLabel || "Parent"}
relations={relations}
<TaskPlannerItemDetails
task={task}
tasks={tasks}
data={data}
parentMap={parentMap}
parentId={parentId}
textContent={textContent}
descriptionOpen={descriptionOpen}
settingsOpen={settingsOpen}
noticeMessage={noticeMessage}
relations={relations}
relationTargets={relationTargets}
emptyLabel={textContent.emptyLinks || "Aucun parent"}
addLabel={textContent.addLinkLabel || "Définir un parent"}
removeLabel={textContent.removeRelationTitle || "Retirer"}
prerequisiteLabel={textContent.prerequisiteCheckboxLabel || "Pré requis"}
onUpdateTask={onUpdateTask}
onAddLinkTarget={onAddLinkTarget}
onRemoveLinkTarget={onRemoveLinkTarget}
onSetPrerequisite={onSetPrerequisite}
onSetTaskCategory={onSetTaskCategory}
/>
</div>
)}
{children && <ul className="task-planner-list task-planner-child-list">{children}</ul>}
</li>
);
}
function MissingPrerequisitesTooltip({ missingPrerequisites, tasks, textContent }) {
return (
<span className="task-planner-prerequisite-tooltip">
<strong>{textContent.missingPrerequisiteTitle || "Pré requis non effectué"}</strong>
<span>
{missingPrerequisites.map((relation) => (
<span key={relation.id}>{getTaskTitle(tasks, relation.toTaskId)}</span>
))}
</span>
</span>
);
}
function TaskResetSettings({ task, data, textContent, onUpdateTask }) {
if (task.type === "daily") {
const hasOverride = Boolean(task.dailyResetTime);
return (
<div className="task-planner-reset-override">
<span>{textContent.taskDailyResetTimeLabel || "Heure quotidienne"}</span>
<div className="task-planner-reset-control">
<input
type="time"
value={task.dailyResetTime || data.resetTime}
onChange={(event) => onUpdateTask(task.id, (current) => {
const nextTask = { ...current };
if (event.target.value && event.target.value !== data.resetTime) nextTask.dailyResetTime = event.target.value;
else delete nextTask.dailyResetTime;
return nextTask;
})}
aria-label={`${textContent.taskDailyResetTimeLabel || "Heure quotidienne"} ${task.title}`}
/>
<button
type="button"
onClick={() => onUpdateTask(task.id, (current) => {
const nextTask = { ...current };
delete nextTask.dailyResetTime;
return nextTask;
})}
disabled={!hasOverride}
title={textContent.inheritResetTime || "Utiliser l'heure globale"}
aria-label={`${textContent.inheritResetTime || "Utiliser l'heure globale"} ${task.title}`}
>
<Icon name="rubber" />
</button>
</div>
</div>
);
}
if (task.type === "weekly") {
return (
<label className="task-planner-reset-override">
<span>{textContent.taskWeeklyResetDayLabel || "Jour hebdo"}</span>
<select
value={task.weeklyResetDay ?? ""}
onChange={(event) => onUpdateTask(task.id, (current) => {
const nextTask = { ...current };
if (event.target.value === "") {
delete nextTask.weeklyResetDay;
} else {
nextTask.weeklyResetDay = Number(event.target.value);
}
return nextTask;
})}
>
<option value="">{textContent.inheritWeeklyResetDay || "Global"}</option>
{WEEK_DAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</label>
);
}
return (
<div className="task-planner-reset-override is-empty">
<span>{textContent.taskResetLabel || "Réinitialisation"}</span>
<p>{textContent.noTaskResetLabel || "Aucun reset"}</p>
</div>
);
}
function LinksEditor({ label, relations, task, tasks, parentMap, relationTargets, emptyLabel, addLabel, removeLabel, prerequisiteLabel, onAddLinkTarget, onRemoveLinkTarget, onSetPrerequisite }) {
const descendantIds = new Set();
function collectDescendants(taskId) {
tasks.filter((item) => parentMap.get(item.id) === taskId).forEach((child) => {
descendantIds.add(child.id);
collectDescendants(child.id);
});
}
collectDescendants(task.id);
const availableTargets = relationTargets.filter((target) => !descendantIds.has(target.id));
return (
<div className="task-planner-relations">
<span>{label}</span>
<select
value=""
onChange={(event) => {
onAddLinkTarget(task.id, event.target.value);
event.target.value = "";
}}
aria-label={addLabel}
>
<option value="">{addLabel}</option>
{availableTargets.map((target) => <option key={target.id} value={target.id}>{target.title}</option>)}
</select>
<div>
{relations.length ? relations.map((relation) => (
<div className="task-planner-relation-chip" key={relation.id}>
<span>{getTaskTitle(tasks, relation.toTaskId)}</span>
<label title={prerequisiteLabel}>
<input
type="checkbox"
checked={relation.prerequisite}
onChange={(event) => onSetPrerequisite(task.id, relation.toTaskId, event.target.checked)}
aria-label={`${prerequisiteLabel} ${getTaskTitle(tasks, relation.toTaskId)}`}
/>
<span>{prerequisiteLabel}</span>
</label>
<button type="button" onClick={() => onRemoveLinkTarget(task.id, relation.toTaskId)} title={removeLabel} aria-label={`${removeLabel} ${getTaskTitle(tasks, relation.toTaskId)}`}>
<Icon name="close" />
</button>
</div>
)) : <em>{emptyLabel}</em>}
</div>
</div>
);
}