Improve toolbox persistence and goal tree coverage
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
This commit is contained in:
parent
50029b45a2
commit
7348969ac4
10 changed files with 801 additions and 591 deletions
379
website/src/features/toolboxes/modules/GoalTreeParts.jsx
Normal file
379
website/src/features/toolboxes/modules/GoalTreeParts.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue