Improve toolbox persistence and goal tree coverage
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 16:11:22 +02:00
parent 50029b45a2
commit 7348969ac4
10 changed files with 801 additions and 591 deletions

View file

@ -0,0 +1,379 @@
// Rôle : rend les sous-composants d'édition et de prévisualisation de l'arbre d'objectifs.
import { useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import {
GOAL_TREE_ICON_NAMES,
formatGoalTreeNumber,
getGoalTreeChildren,
getGoalTreeDescendantIds,
getGoalTreeParentIds
} from "./goalTreeUtils.js";
import {
BASE_TOKEN,
getEffectiveGoalTreeProgressMode,
getGoalTreeNodeIcon,
getGoalTreeNodeIconColor,
getGoalTreeNodeTooltip,
isSingleValueGoalTreeNode
} from "./goalTreeDisplay.js";
export function GoalTreePrerequisitePicker({ nodes, node, textContent, onToggle }) {
const parentIds = new Set(getGoalTreeParentIds(node));
const blockedIds = new Set([node.id, ...getGoalTreeDescendantIds(nodes, node.id)]);
const primaryParent = nodes.find((candidate) => candidate.id === node.parentId);
const options = nodes.filter((candidate) => !blockedIds.has(candidate.id) && candidate.id !== node.parentId);
if (!primaryParent && !options.length) return null;
return (
<div className="goal-tree-prerequisite-field">
<span className="goal-tree-form-section-title">{textContent.prerequisitesSectionTitle || "Prérequis supplémentaires"}</span>
<div className="goal-tree-prerequisite-list themed-scrollbar">
{primaryParent && (
<label className="goal-tree-prerequisite-item is-primary">
<input type="checkbox" checked disabled readOnly />
<span>{primaryParent.title}</span>
</label>
)}
{options.map((candidate) => (
<label className="goal-tree-prerequisite-item" key={candidate.id}>
<input type="checkbox" checked={parentIds.has(candidate.id)} onChange={() => onToggle(candidate.id)} />
<span>{candidate.title}</span>
</label>
))}
</div>
</div>
);
}
export function GoalTreeBranch({ nodes, parentId, selectedNodeId, editing, reorder, textContent, onSelect, onAdd, onAddContentItem, onSetStepLeafQuantity, onSetStepChildrenCompletion, onUpdateNodeItem, onEditNode, onToggleBranch, onDeleteNode }) {
const children = getGoalTreeChildren(nodes, parentId);
if (!children.length) return null;
return (
<ul className={`goal-tree-list ${parentId ? "is-child-list" : "is-root-list"}`} data-goal-tree-parent={parentId || "root"}>
{children.map((node) => {
const nestedChildren = getGoalTreeChildren(nodes, node.id);
const linkedChildren = nodes.filter((candidate) => candidate.parentId !== node.id && getGoalTreeParentIds(candidate).includes(node.id));
const branchCollapsed = node.collapsed;
const isSelected = selectedNodeId === node.id;
const isComplete = node.progress.complete;
const hasSingleValueChildren = nestedChildren.length > 0 && nestedChildren.every(isSingleValueGoalTreeNode);
const manualProgress = getEffectiveGoalTreeProgressMode(node, nestedChildren) === "manual";
const hasBranchToggle = nestedChildren.length > 0 || linkedChildren.length > 0;
const hasNodeActions = editing || hasBranchToggle;
const iconColor = getGoalTreeNodeIconColor(node);
const selectFromCard = (event) => {
if (!editing) return;
if (event.target.closest("button:not(.goal-tree-node-main)")) return;
onSelect(node.id);
};
const className = [
"goal-tree-node",
`is-${node.kind}`,
isSelected ? "is-selected" : "",
isComplete ? "is-complete" : "",
reorder.isItemDragging(node.id) ? "is-dragging" : "",
reorder.isItemDropTarget(node.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", node.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li key={node.id} className={className} {...reorder.getItemProps({ itemId: node.id, parentId })}>
<article className={`goal-tree-node-card ${editing ? "is-editing" : "is-viewing"} ${hasNodeActions ? "has-actions" : ""}`} data-diagram-node={node.id} onClick={selectFromCard}>
{!editing && (
<div className="goal-tree-node-hover-actions" aria-label={textContent.cardActionsLabel || "Actions de l'objectif"}>
<button type="button" onClick={() => onEditNode(node.id)} aria-label={`${textContent.editTitle || "Modifier"} ${node.title}`} title={textContent.editTitle || "Modifier"}>
<Icon name="edit" />
</button>
<button className="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
)}
{editing && (
<button
className="goal-tree-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, node.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${node.title}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
)}
<Tooltip as="div" className="goal-tree-node-main" content={node.kind === "step" ? getGoalTreeNodeTooltip(node) : ""} tooltipClassName="goal-tree-node-tooltip" aria-label={node.title}>
{node.kind === "step" ? (
<>
<span className="goal-tree-node-icon" aria-hidden="true" style={{ "--goal-tree-node-color": iconColor }}>
<Icon name={getGoalTreeNodeIcon(node)} />
</span>
{(manualProgress || !nestedChildren.length) && (
<GoalTreeStepLeafControl node={node} textContent={textContent} onChange={(quantity) => onSetStepLeafQuantity(node.id, quantity)} />
)}
{!manualProgress && hasSingleValueChildren && (
<GoalTreeStepLeafControl
node={{ ...node, computedQtyTarget: 1, qtyTarget: 1, qtyCurrent: node.progress.complete ? 1 : 0 }}
textContent={textContent}
onChange={(quantity) => onSetStepChildrenCompletion(node.id, quantity > 0)}
/>
)}
</>
) : (
<>
<span className="goal-tree-node-heading">
<span className="goal-tree-node-icon" aria-hidden="true" style={{ "--goal-tree-node-color": iconColor }}>
<Icon name={getGoalTreeNodeIcon(node)} />
</span>
<span className="goal-tree-node-title">{node.title}</span>
</span>
<GoalTreeNodeContentPreview
node={node}
editing={editing}
textContent={textContent}
onAddContentItem={() => onAddContentItem(node.id)}
onUpdateItem={(itemId, updater) => onUpdateNodeItem(node.id, itemId, updater)}
/>
</>
)}
<span className="goal-tree-progress-track" aria-hidden="true">
<span style={{ width: `${Math.round(node.progress.ratio * 100)}%` }} />
</span>
</Tooltip>
{hasNodeActions && (
<div className="goal-tree-node-actions">
{editing && (
<button type="button" onClick={() => onAdd(node.id)} aria-label={`${textContent.addChildTitle || "Ajouter une sous-étape"} ${node.title}`} title={textContent.addChildTitle || "Ajouter une sous-étape"}>
<Icon name="add" />
</button>
)}
{hasBranchToggle && (
<button
className="goal-tree-branch-toggle"
type="button"
onClick={() => onToggleBranch(node.id)}
aria-expanded={!branchCollapsed}
aria-label={branchCollapsed ? textContent.showChildrenTitle || "Afficher la branche" : textContent.hideChildrenTitle || "Réduire la branche"}
title={branchCollapsed ? textContent.showChildrenTitle || "Afficher la branche" : textContent.hideChildrenTitle || "Réduire la branche"}
>
<Icon name={branchCollapsed ? "chevron-down" : "chevron-up"} />
</button>
)}
{editing && (
<button className="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
)}
</div>
)}
</article>
{!node.collapsed && nestedChildren.length > 0 && (
<GoalTreeBranch
nodes={nodes}
parentId={node.id}
selectedNodeId={selectedNodeId}
editing={editing}
reorder={reorder}
textContent={textContent}
onSelect={onSelect}
onAdd={onAdd}
onAddContentItem={onAddContentItem}
onSetStepLeafQuantity={onSetStepLeafQuantity}
onSetStepChildrenCompletion={onSetStepChildrenCompletion}
onUpdateNodeItem={onUpdateNodeItem}
onEditNode={onEditNode}
onToggleBranch={onToggleBranch}
onDeleteNode={onDeleteNode}
/>
)}
</li>
);
})}
</ul>
);
}
export function GoalTreeTitleInput({ value, onChange }) {
const [draft, setDraft] = useState(value || "");
return (
<input
value={draft}
onChange={(event) => {
const title = event.target.value;
setDraft(title);
onChange(title);
}}
/>
);
}
export function GoalTreeFormulaInput({ value, onChange, hasParent, textContent, placeholder, title, className = "", "aria-label": ariaLabel }) {
const normalizedValue = String(value ?? "");
if (!hasParent) {
return <input aria-label={ariaLabel} className={className} inputMode="decimal" onChange={(event) => onChange(event.target.value.replaceAll(",", "."))} placeholder={placeholder} title={title} value={normalizedValue} />;
}
return (
<MixTagsInput
aria-label={ariaLabel}
className={className}
formulaSpacing
inputMode="decimal"
insertToken={{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
onChange={(nextValue) => onChange(nextValue.replaceAll(",", "."))}
placeholder={placeholder}
title={title}
tokens={[{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={normalizedValue}
/>
);
}
export function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete }) {
return (
<div className="goal-tree-content-editor">
<div className="goal-tree-form-section-title">{textContent.contentSectionTitle || "Contenu"}</div>
{(node.items || []).map((item) => (
<div className={`goal-tree-content-editor-row is-${node.kind}`} key={item.id}>
<input className="goal-tree-content-label-input" value={item.label} placeholder={textContent.itemLabelPlaceholder || "Élément"} onChange={(event) => onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))} aria-label={textContent.itemLabelLabel || "Libellé"} />
{node.kind === "checklist" && (
<GoalTreeFormulaInput
aria-label={textContent.itemTotalLabel || "Total"}
className="goal-tree-content-formula-input"
hasParent={Boolean(node.parentId)}
onChange={(value) => onUpdate(item.id, (current) => ({ ...current, formula: value.replaceAll(",", ".") }))}
value={item.formula || "1"}
placeholder={textContent.itemTotalPlaceholder || "base"}
textContent={textContent}
title={textContent.itemTotalHelp || "Saisissez un nombre ou une formule avec base pour référencer la cible du parent."}
/>
)}
<button className="danger" type="button" onClick={() => onDelete(item.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
))}
<button className="goal-tree-content-add-button" type="button" onClick={onAdd}>
<Icon name="add" />
<span>{textContent.addContentItemButton || "Ajouter une ligne"}</span>
</button>
</div>
);
}
function GoalTreeChecklistInlineControl({ item, textContent, onUpdate }) {
const target = Math.max(1, item.qtyTarget || 1);
const current = Math.min(Math.max(0, item.qtyCurrent || 0), target);
if (target <= 1) {
return <input className="goal-tree-node-check" type="checkbox" checked={current >= 1} onChange={(event) => onUpdate((entry) => ({ ...entry, qtyCurrent: event.target.checked ? 1 : 0 }))} aria-label={textContent.itemCheckedLabel || "État"} />;
}
return (
<span className="goal-tree-content-qty-stepper">
<button type="button" onClick={() => onUpdate((entry) => ({ ...entry, qtyCurrent: Math.max(0, (entry.qtyCurrent || 0) - 1) }))} aria-label={textContent.decrementItemTitle || "Retirer"} title={textContent.decrementItemTitle || "Retirer"}>
<span aria-hidden="true">-</span>
</button>
<span>{formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)}</span>
<button type="button" onClick={() => onUpdate((entry) => ({ ...entry, qtyCurrent: Math.min(target, (entry.qtyCurrent || 0) + 1) }))} aria-label={textContent.incrementItemTitle || "Ajouter"} title={textContent.incrementItemTitle || "Ajouter"}>
<Icon name="add" />
</button>
</span>
);
}
function GoalTreeStepLeafControl({ node, textContent, onChange }) {
const target = Math.max(1, node.computedQtyTarget || node.qtyTarget || 1);
const current = Math.min(Math.max(0, node.qtyCurrent || 0), target);
if (target <= 1) {
return <input className="goal-tree-step-check" type="checkbox" checked={current >= 1} onChange={(event) => onChange(event.target.checked ? 1 : 0)} aria-label={textContent.itemCheckedLabel || "État"} />;
}
return (
<span className="goal-tree-step-qty-stepper goal-tree-content-qty-stepper">
<button type="button" onClick={() => onChange(Math.max(0, current - 1))} aria-label={textContent.decrementItemTitle || "Retirer"} title={textContent.decrementItemTitle || "Retirer"}>
<span aria-hidden="true">-</span>
</button>
<span>{formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)}</span>
<button type="button" onClick={() => onChange(Math.min(target, current + 1))} aria-label={textContent.incrementItemTitle || "Ajouter"} title={textContent.incrementItemTitle || "Ajouter"}>
<Icon name="add" />
</button>
</span>
);
}
function GoalTreeNodeContentPreview({ node, editing, textContent, onAddContentItem, onUpdateItem }) {
const items = node.items || [];
return (
<span className="goal-tree-node-content-preview">
{items.map((item) => (
<span className="goal-tree-node-content-row" key={item.id}>
{node.kind === "checklist" && <GoalTreeChecklistInlineControl item={item} textContent={textContent} onUpdate={(updater) => onUpdateItem(item.id, updater)} />}
<Tooltip as="span" className="goal-tree-node-content-label" content={item.label} tooltipClassName="goal-tree-node-tooltip">
{item.label}
</Tooltip>
</span>
))}
{editing && (
<Tooltip
as="button"
className="goal-tree-node-content-add-button"
type="button"
onClick={onAddContentItem}
content={textContent.addContentItemTooltip || "Ajouter une entrée interne à cette carte."}
tooltipClassName="goal-tree-node-tooltip"
aria-label={`${textContent.addContentItemTitle || "Ajouter une entrée"} ${node.title}`}
>
<Icon name="add" />
</Tooltip>
)}
</span>
);
}
export function GoalTreeIconPicker({ label, value, textContent, onChange }) {
return (
<div className="goal-tree-icon-field">
<span>{label}</span>
<CompactDropdown
className="goal-tree-icon-picker"
menuClassName="goal-tree-icon-picker-menu"
label={label}
renderTrigger={({ open, toggle }) => (
<button className="goal-tree-icon-picker-button" type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={label}>
<span className="goal-tree-icon-preview" aria-hidden="true">
<Icon name={value} />
</span>
<span>{textContent[`icon${value}`] || value}</span>
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<>
{GOAL_TREE_ICON_NAMES.map((icon) => (
<button
key={icon}
className={icon === value ? "active" : ""}
type="button"
onClick={() => {
onChange(icon);
close();
}}
aria-label={textContent[`icon${icon}`] || icon}
aria-pressed={icon === value}
title={textContent[`icon${icon}`] || icon}
>
<Icon name={icon} />
</button>
))}
</>
)}
</CompactDropdown>
</div>
);
}