Refactor goal tree editor form
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 22:07:22 +02:00
parent a796edcb02
commit d9bbf40fad
3 changed files with 255 additions and 169 deletions

View file

@ -165,7 +165,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
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 goalTreeEditor = await readFile("website/src/features/toolboxes/modules/GoalTreeEditor.jsx", "utf8");
const goalTreeParts = await readFile("website/src/features/toolboxes/modules/GoalTreeParts.jsx", "utf8");
const goalTreeBundle = `${goalTreeModule}\n${goalTreeEditor}\n${goalTreeParts}`;
const goalTreeDisplay = await readFile("website/src/features/toolboxes/modules/goalTreeDisplay.js", "utf8");
const goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.js", "utf8");
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8");
@ -381,11 +383,15 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(goalTreeModule, /applyGoalTreeDueResets/);
assert.match(goalTreeModule, /TextExchangeActions/);
assert.match(goalTreeModule, /DiagramConnectors/);
assert.match(goalTreeEditor, /export function GoalTreeEditor/);
assert.match(goalTreeEditor, /Tabs/);
assert.match(goalTreeEditor, /GoalTreeFormulaInput/);
assert.match(goalTreeEditor, /GoalTreePrerequisitePicker/);
assert.match(goalTreeParts, /Tooltip/);
assert.match(goalTreeParts, /export function GoalTreeIconPicker/);
assert.match(goalTreeParts, /export function GoalTreeContentEditor/);
assert.match(goalTreeParts, /function GoalTreeNodeContentPreview/);
assert.doesNotMatch(`${goalTreeModule}\n${goalTreeParts}`, /\.slice\(0, 3\)/);
assert.doesNotMatch(goalTreeBundle, /\.slice\(0, 3\)/);
assert.match(goalTreeParts, /GoalTreeChecklistInlineControl/);
assert.match(goalTreeParts, /GoalTreeStepLeafControl/);
assert.match(goalTreeParts, /export function GoalTreePrerequisitePicker/);

View file

@ -0,0 +1,227 @@
// Rôle : rend le formulaire d'édition des noeuds de l'arbre d'objectifs.
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import {
GoalTreeContentEditor,
GoalTreeFormulaInput,
GoalTreeIconPicker,
GoalTreePrerequisitePicker,
GoalTreeTitleInput
} from "./GoalTreeParts.jsx";
import {
KIND_LABELS,
RESET_LABELS
} from "./goalTreeDisplay.js";
import {
GOAL_TREE_COLORS,
GOAL_TREE_COLOR_VALUES,
GOAL_TREE_KINDS,
GOAL_TREE_RESET_TYPES,
GOAL_TREE_WEEK_DAYS
} from "./goalTreeUtils.js";
export function GoalTreeEditor({
selectedNode,
selectedNodeIsStep,
selectedProgressMode,
formTabs,
activeFormTab,
evaluatedNodes,
textContent,
onTabChange,
onClose,
onDeleteNode,
onSetKind,
onUpdateNode,
onSetStepTarget,
onAddNodeItem,
onUpdateNodeItem,
onDeleteNodeItem,
onToggleLinkedParent
}) {
if (!selectedNode) {
return (
<section className="goal-tree-editor calculator-card" aria-label={textContent.editorTitle || "Édition de l'arbre d'objectifs"}>
<p className="goal-tree-empty">{textContent.emptySelection || "Sélectionnez ou créez un objectif."}</p>
</section>
);
}
return (
<section className="goal-tree-editor calculator-card" aria-label={textContent.editorTitle || "Édition de l'arbre d'objectifs"}>
<div className="goal-tree-selected-form">
<Tabs tabs={formTabs} activeTab={activeFormTab} onChange={onTabChange} className="goal-tree-form-tabs" />
{activeFormTab === "base" && (
<GoalTreeBasePanel
selectedNode={selectedNode}
selectedNodeIsStep={selectedNodeIsStep}
textContent={textContent}
onSetKind={onSetKind}
onUpdateNode={onUpdateNode}
/>
)}
{activeFormTab === "content" && !selectedNodeIsStep && (
<div className="goal-tree-form-panel">
<GoalTreeContentEditor
node={selectedNode}
textContent={textContent}
onAdd={() => onAddNodeItem(selectedNode.id)}
onUpdate={(itemId, updater) => onUpdateNodeItem(selectedNode.id, itemId, updater)}
onDelete={(itemId) => onDeleteNodeItem(selectedNode.id, itemId)}
/>
</div>
)}
{activeFormTab === "content" && selectedNodeIsStep && (
<div className="goal-tree-form-panel">
<p className="goal-tree-content-hint">{textContent.stepContentUnavailable || "Les étapes n'ont pas de contenu interne."}</p>
</div>
)}
{activeFormTab === "advanced" && (
<GoalTreeAdvancedPanel
selectedNode={selectedNode}
selectedNodeIsStep={selectedNodeIsStep}
selectedProgressMode={selectedProgressMode}
evaluatedNodes={evaluatedNodes}
textContent={textContent}
onUpdateNode={onUpdateNode}
onSetStepTarget={onSetStepTarget}
onToggleLinkedParent={onToggleLinkedParent}
/>
)}
<div className="goal-tree-form-actions">
<button type="button" onClick={onClose}>
<Icon name="close" />
<span>{textContent.closeButton || "Fermer"}</span>
</button>
<button className="danger" type="button" onClick={() => onDeleteNode(selectedNode.id)}>
<Icon name="trash" />
<span>{textContent.deleteButton || "Supprimer la branche"}</span>
</button>
</div>
</div>
</section>
);
}
function GoalTreeBasePanel({ selectedNode, selectedNodeIsStep, textContent, onSetKind, onUpdateNode }) {
return (
<div className="goal-tree-form-panel">
<label>
<span>{textContent.kindLabel || "Type de l'objectif"}</span>
<select value={selectedNode.kind} onChange={(event) => onSetKind(selectedNode.id, event.target.value)}>
{GOAL_TREE_KINDS.map((kind) => <option key={kind} value={kind}>{textContent[`${kind}Kind`] || KIND_LABELS[kind]}</option>)}
</select>
</label>
<label>
<span>{selectedNodeIsStep ? textContent.titleLabel || "Titre de l'objectif" : textContent.listTitleLabel || "Titre de la liste"}</span>
<GoalTreeTitleInput
key={selectedNode.id}
value={selectedNode.title}
onChange={(title) => onUpdateNode(selectedNode.id, (node) => ({ ...node, title }))}
/>
</label>
{selectedNodeIsStep && (
<>
<GoalTreeIconPicker
label={textContent.iconLabel || "Icône"}
value={selectedNode.icon || "cube"}
textContent={textContent}
onChange={(icon) => onUpdateNode(selectedNode.id, (node) => ({ ...node, icon }))}
/>
<div className="goal-tree-color-field">
<span>{textContent.iconColorLabel || "Couleur de l'icône"}</span>
<div className="goal-tree-color-swatches" role="radiogroup" aria-label={textContent.iconColorLabel || "Couleur de l'icône"}>
{GOAL_TREE_COLORS.map((color) => (
<button
key={color}
className={`goal-tree-color-swatch ${selectedNode.color === color ? "active" : ""}`}
type="button"
onClick={() => onUpdateNode(selectedNode.id, (node) => ({ ...node, color }))}
aria-pressed={selectedNode.color === color}
aria-label={textContent[`color${color}`] || color}
title={textContent[`color${color}`] || color}
style={{ "--goal-tree-swatch-color": GOAL_TREE_COLOR_VALUES[color] }}
/>
))}
</div>
</div>
</>
)}
{!selectedNodeIsStep && (
<p className="goal-tree-content-hint">{textContent.contentTabHint || "Le contenu se modifie dans l'onglet Contenu."}</p>
)}
</div>
);
}
function GoalTreeAdvancedPanel({ selectedNode, selectedNodeIsStep, selectedProgressMode, evaluatedNodes, textContent, onUpdateNode, onSetStepTarget, onToggleLinkedParent }) {
return (
<div className="goal-tree-form-panel">
<label>
<span>{textContent.resetTypeLabel || "Type de reset"}</span>
<select value={selectedNode.resetType} onChange={(event) => onUpdateNode(selectedNode.id, (node) => ({ ...node, resetType: event.target.value }))}>
{GOAL_TREE_RESET_TYPES.map((resetType) => <option key={resetType} value={resetType}>{textContent[`${resetType}Reset`] || RESET_LABELS[resetType]}</option>)}
</select>
</label>
{selectedNode.resetType !== "none" && (
<label>
<span>{textContent.resetTimeLabel || "Heure de reset"}</span>
<input type="time" value={selectedNode.resetTime || "00:00"} onChange={(event) => onUpdateNode(selectedNode.id, (node) => ({ ...node, resetTime: event.target.value || "00:00" }))} />
</label>
)}
{selectedNode.resetType === "weekly" && (
<label>
<span>{textContent.weeklyResetDayLabel || "Jour de reset"}</span>
<select value={selectedNode.weeklyResetDay ?? 1} onChange={(event) => onUpdateNode(selectedNode.id, (node) => ({ ...node, weeklyResetDay: Number(event.target.value) }))}>
{GOAL_TREE_WEEK_DAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</label>
)}
{selectedNodeIsStep && (
<>
<div className="goal-tree-form-section-title">{textContent.quantitySectionTitle || "Quantité"}</div>
<label>
<span>{textContent.targetQuantityLabel || "Cible"}</span>
<GoalTreeFormulaInput
aria-label={textContent.targetQuantityLabel || "Cible"}
hasParent={Boolean(selectedNode.parentId)}
onChange={(value) => onSetStepTarget(selectedNode.id, value)}
value={selectedNode.formula || selectedNode.qtyTarget}
placeholder={textContent.formulaPlaceholder || "base * 3"}
textContent={textContent}
title={textContent.formulaHelp || "Utilisez base pour référencer la cible du parent."}
/>
</label>
<div className="goal-tree-form-section-title">{textContent.progressSectionTitle || "Progression"}</div>
<button
className={`tool-split-scroll-toggle goal-tree-progress-mode-toggle ${selectedProgressMode === "auto" ? "active" : ""}`}
type="button"
onClick={() => onUpdateNode(selectedNode.id, (node) => ({ ...node, progressMode: selectedProgressMode === "auto" ? "manual" : "auto" }))}
aria-pressed={selectedProgressMode === "auto"}
title={selectedProgressMode === "auto" ? textContent.autoProgressTitle || "Progression automatique" : textContent.manualProgressTitle || "Mise à jour manuelle"}
>
<Icon name={selectedProgressMode === "auto" ? "calculator" : "edit"} />
<span>{selectedProgressMode === "auto" ? textContent.autoProgressLabel || "Automatique" : textContent.manualProgressLabel || "Manuelle"}</span>
<i aria-hidden="true" />
</button>
<p className="goal-tree-progress-mode-hint">
{selectedProgressMode === "auto"
? textContent.autoProgressHint || "Calcule la progression depuis les enfants."
: textContent.manualProgressHint || "Affiche une checkbox ou une quantité à mettre à jour directement."}
</p>
</>
)}
<GoalTreePrerequisitePicker
nodes={evaluatedNodes}
node={selectedNode}
textContent={textContent}
onToggle={(parentId) => onToggleLinkedParent(selectedNode.id, parentId)}
/>
</div>
);
}

View file

@ -2,22 +2,13 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { GoalTreeEditor } from "./GoalTreeEditor.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
GoalTreeBranch,
GoalTreeContentEditor,
GoalTreeFormulaInput,
GoalTreeIconPicker,
GoalTreePrerequisitePicker,
GoalTreeTitleInput
} from "./GoalTreeParts.jsx";
import { GoalTreeBranch } from "./GoalTreeParts.jsx";
import {
GOAL_TREE_VIEW_SCALE,
KIND_ICONS,
KIND_LABELS,
RESET_LABELS,
getDefaultGoalTreeItem,
getEffectiveGoalTreeProgressMode,
getGoalTreeConvergenceKeepOpenIds,
@ -26,11 +17,6 @@ import {
isSingleValueGoalTreeNode
} from "./goalTreeDisplay.js";
import {
GOAL_TREE_COLORS,
GOAL_TREE_COLOR_VALUES,
GOAL_TREE_KINDS,
GOAL_TREE_RESET_TYPES,
GOAL_TREE_WEEK_DAYS,
applyGoalTreeDueResets,
evaluateGoalTreeNodes,
getGoalTreeChildren,
@ -329,158 +315,25 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
return (
<div className={`goal-tree-module ${editing ? "" : "is-view-only"}`}>
{editing && (
<section className="goal-tree-editor calculator-card" aria-label={textContent.editorTitle || "Édition de l'arbre d'objectifs"}>
{selectedNode ? (
<div className="goal-tree-selected-form">
<Tabs tabs={formTabs} activeTab={activeFormTab} onChange={setActiveFormTab} className="goal-tree-form-tabs" />
{activeFormTab === "base" && (
<div className="goal-tree-form-panel">
<label>
<span>{textContent.kindLabel || "Type de l'objectif"}</span>
<select value={selectedNode.kind} onChange={(event) => setKind(selectedNode.id, event.target.value)}>
{GOAL_TREE_KINDS.map((kind) => <option key={kind} value={kind}>{textContent[`${kind}Kind`] || KIND_LABELS[kind]}</option>)}
</select>
</label>
<label>
<span>{selectedNodeIsStep ? textContent.titleLabel || "Titre de l'objectif" : textContent.listTitleLabel || "Titre de la liste"}</span>
<GoalTreeTitleInput
key={selectedNode.id}
value={selectedNode.title}
onChange={(title) => updateNode(selectedNode.id, (node) => ({ ...node, title }))}
/>
</label>
{selectedNodeIsStep && (
<>
<GoalTreeIconPicker
label={textContent.iconLabel || "Icône"}
value={selectedNode.icon || "cube"}
<GoalTreeEditor
selectedNode={selectedNode}
selectedNodeIsStep={selectedNodeIsStep}
selectedProgressMode={selectedProgressMode}
formTabs={formTabs}
activeFormTab={activeFormTab}
evaluatedNodes={evaluatedNodes}
textContent={textContent}
onChange={(icon) => updateNode(selectedNode.id, (node) => ({ ...node, icon }))}
onTabChange={setActiveFormTab}
onClose={() => setEditing(false)}
onDeleteNode={deleteNode}
onSetKind={setKind}
onUpdateNode={updateNode}
onSetStepTarget={setStepTarget}
onAddNodeItem={addNodeItem}
onUpdateNodeItem={updateNodeItem}
onDeleteNodeItem={deleteNodeItem}
onToggleLinkedParent={toggleLinkedParent}
/>
<div className="goal-tree-color-field">
<span>{textContent.iconColorLabel || "Couleur de l'icône"}</span>
<div className="goal-tree-color-swatches" role="radiogroup" aria-label={textContent.iconColorLabel || "Couleur de l'icône"}>
{GOAL_TREE_COLORS.map((color) => (
<button
key={color}
className={`goal-tree-color-swatch ${selectedNode.color === color ? "active" : ""}`}
type="button"
onClick={() => updateNode(selectedNode.id, (node) => ({ ...node, color }))}
aria-pressed={selectedNode.color === color}
aria-label={textContent[`color${color}`] || color}
title={textContent[`color${color}`] || color}
style={{ "--goal-tree-swatch-color": GOAL_TREE_COLOR_VALUES[color] }}
/>
))}
</div>
</div>
</>
)}
{!selectedNodeIsStep && (
<p className="goal-tree-content-hint">{textContent.contentTabHint || "Le contenu se modifie dans l'onglet Contenu."}</p>
)}
</div>
)}
{activeFormTab === "content" && !selectedNodeIsStep && (
<div className="goal-tree-form-panel">
<GoalTreeContentEditor
node={selectedNode}
textContent={textContent}
onAdd={() => addNodeItem(selectedNode.id)}
onUpdate={(itemId, updater) => updateNodeItem(selectedNode.id, itemId, updater)}
onDelete={(itemId) => deleteNodeItem(selectedNode.id, itemId)}
/>
</div>
)}
{activeFormTab === "content" && selectedNodeIsStep && (
<div className="goal-tree-form-panel">
<p className="goal-tree-content-hint">{textContent.stepContentUnavailable || "Les étapes n'ont pas de contenu interne."}</p>
</div>
)}
{activeFormTab === "advanced" && (
<div className="goal-tree-form-panel">
<label>
<span>{textContent.resetTypeLabel || "Type de reset"}</span>
<select value={selectedNode.resetType} onChange={(event) => updateNode(selectedNode.id, (node) => ({ ...node, resetType: event.target.value }))}>
{GOAL_TREE_RESET_TYPES.map((resetType) => <option key={resetType} value={resetType}>{textContent[`${resetType}Reset`] || RESET_LABELS[resetType]}</option>)}
</select>
</label>
{selectedNode.resetType !== "none" && (
<label>
<span>{textContent.resetTimeLabel || "Heure de reset"}</span>
<input type="time" value={selectedNode.resetTime || "00:00"} onChange={(event) => updateNode(selectedNode.id, (node) => ({ ...node, resetTime: event.target.value || "00:00" }))} />
</label>
)}
{selectedNode.resetType === "weekly" && (
<label>
<span>{textContent.weeklyResetDayLabel || "Jour de reset"}</span>
<select value={selectedNode.weeklyResetDay ?? 1} onChange={(event) => updateNode(selectedNode.id, (node) => ({ ...node, weeklyResetDay: Number(event.target.value) }))}>
{GOAL_TREE_WEEK_DAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</label>
)}
{selectedNodeIsStep && (
<>
<div className="goal-tree-form-section-title">{textContent.quantitySectionTitle || "Quantité"}</div>
<label>
<span>{textContent.targetQuantityLabel || "Cible"}</span>
<GoalTreeFormulaInput
aria-label={textContent.targetQuantityLabel || "Cible"}
hasParent={Boolean(selectedNode.parentId)}
onChange={(value) => setStepTarget(selectedNode.id, value)}
value={selectedNode.formula || selectedNode.qtyTarget}
placeholder={textContent.formulaPlaceholder || "base * 3"}
textContent={textContent}
title={textContent.formulaHelp || "Utilisez base pour référencer la cible du parent."}
/>
</label>
<div className="goal-tree-form-section-title">{textContent.progressSectionTitle || "Progression"}</div>
<button
className={`tool-split-scroll-toggle goal-tree-progress-mode-toggle ${selectedProgressMode === "auto" ? "active" : ""}`}
type="button"
onClick={() => updateNode(selectedNode.id, (node) => ({ ...node, progressMode: selectedProgressMode === "auto" ? "manual" : "auto" }))}
aria-pressed={selectedProgressMode === "auto"}
title={selectedProgressMode === "auto" ? textContent.autoProgressTitle || "Progression automatique" : textContent.manualProgressTitle || "Mise à jour manuelle"}
>
<Icon name={selectedProgressMode === "auto" ? "calculator" : "edit"} />
<span>{selectedProgressMode === "auto" ? textContent.autoProgressLabel || "Automatique" : textContent.manualProgressLabel || "Manuelle"}</span>
<i aria-hidden="true" />
</button>
<p className="goal-tree-progress-mode-hint">
{selectedProgressMode === "auto"
? textContent.autoProgressHint || "Calcule la progression depuis les enfants."
: textContent.manualProgressHint || "Affiche une checkbox ou une quantité à mettre à jour directement."}
</p>
</>
)}
<GoalTreePrerequisitePicker
nodes={evaluatedNodes}
node={selectedNode}
textContent={textContent}
onToggle={(parentId) => toggleLinkedParent(selectedNode.id, parentId)}
/>
</div>
)}
<div className="goal-tree-form-actions">
<button type="button" onClick={() => setEditing(false)}>
<Icon name="close" />
<span>{textContent.closeButton || "Fermer"}</span>
</button>
<button className="danger" type="button" onClick={() => deleteNode(selectedNode.id)}>
<Icon name="trash" />
<span>{textContent.deleteButton || "Supprimer la branche"}</span>
</button>
</div>
</div>
) : (
<p className="goal-tree-empty">{textContent.emptySelection || "Sélectionnez ou créez un objectif."}</p>
)}
</section>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">