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,33 @@
{
"toolbox": {
"id": "fixture-toolbox",
"name": "Fixture Toolbox",
"updatedAt": "2026-08-24T10:00:00.000Z",
"modules": [
{
"id": "fixture-note",
"type": "notepad",
"title": "Fixture Notes"
}
],
"moduleColumns": 1,
"moduleOrder": {
"one": [
"fixture-note"
],
"two": [
[
"fixture-note"
],
[]
]
}
},
"modules": {
"fixture-note": {
"html": "<p>Imported fixture note</p>",
"text": "Imported fixture note",
"updatedAt": "2026-08-24T10:00:00.000Z"
}
}
}

30
e2e/helpers/storage.js Normal file
View file

@ -0,0 +1,30 @@
// Rôle : isole les scénarios Playwright en vidant le stockage local Sokko G.
/* global window */
export async function resetSokkoStorage(page) {
await page.goto("/");
await page.evaluate(async () => {
window.localStorage.clear();
window.sessionStorage.clear();
await new Promise((resolve, reject) => {
const request = window.indexedDB.open("sokkog", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("kv")) db.createObjectStore("kv");
if (!db.objectStoreNames.contains("modules")) db.createObjectStore("modules", { keyPath: "key" });
};
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(["kv", "modules"], "readwrite");
transaction.objectStore("kv").clear();
transaction.objectStore("modules").clear();
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
};
});
});
}

65
e2e/toolbox.spec.js Normal file
View file

@ -0,0 +1,65 @@
// Rôle : couvre les parcours navigateur principaux des toolboxes locales.
import { expect, test } from "@playwright/test";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resetSokkoStorage } from "./helpers/storage.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturePath = path.join(__dirname, "fixtures", "toolbox-export.json");
async function createToolbox(page, name) {
await page.goto("/#/toolboxes");
await expect(page.getByRole("main")).toBeVisible();
await page.getByRole("button", { name: "Nouvelle toolbox" }).click();
const dialog = page.getByRole("dialog", { name: "Nouvelle toolbox" });
await expect(dialog).toBeVisible();
await dialog.getByLabel("Nom").fill(name);
await dialog.getByRole("button", { name: "Créer" }).click();
await expect(page.getByRole("heading", { name })).toBeVisible();
}
test.beforeEach(async ({ page }) => {
await resetSokkoStorage(page);
});
test("creates a toolbox and lazy-loads an added tool", async ({ page }) => {
await createToolbox(page, "E2E Toolbox");
await expect(page.getByRole("heading", { name: "E2E Toolbox" })).toBeVisible();
await expect(page.locator("[data-module-type='notepad']")).toBeVisible();
await expect(page.locator("[data-module-type='links']")).toBeVisible();
await page.getByRole("button", { name: "Ajouter un outil", exact: true }).click();
const addDialog = page.getByRole("dialog", { name: "Ajouter un outil" });
await addDialog.getByRole("button", { name: "Arbre d'objectifs" }).click();
await expect(page.locator("[data-module-type='goalTree']")).toBeVisible();
});
test("creates a game-linked toolbox from the drawer", async ({ page }) => {
await page.goto("/#/games/mhwilds");
await expect(page.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible();
await page.getByRole("button", { name: "Associer une toolbox" }).click();
const drawer = page.locator("#toolbox-drawer .drawer-panel");
await expect(drawer).toBeVisible();
await drawer.getByRole("button", { name: "Créer et associer" }).click();
const dialog = page.getByRole("dialog", { name: "Créer et associer une toolbox" });
await expect(dialog.getByLabel("Nom")).toHaveValue(/Monster Hunter.*Wilds/);
await dialog.getByRole("button", { name: "Créer" }).click();
await expect(drawer.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible();
await expect(drawer.locator("[data-module-type='notepad']")).toBeVisible();
await expect(page.getByRole("button", { name: /Ouvrir la toolbox Monster Hunter.*Wilds/ })).toBeVisible();
});
test("imports and exports a toolbox payload", async ({ page }) => {
await page.goto("/#/toolboxes");
await expect(page.getByRole("main")).toBeVisible();
await page.getByText("Importer une toolbox").locator("input[type='file']").setInputFiles(fixturePath);
await expect(page.getByRole("link", { name: "Ouvrir Fixture Toolbox" }).first()).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Exporter Fixture Toolbox" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("fixture-toolbox.sokkog.json");
});

View file

@ -156,6 +156,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
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");
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");
const equipmentPlannerEditors = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx", "utf8");
@ -367,14 +369,14 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(goalTreeModule, /applyGoalTreeDueResets/);
assert.match(goalTreeModule, /TextExchangeActions/);
assert.match(goalTreeModule, /DiagramConnectors/);
assert.match(goalTreeModule, /Tooltip/);
assert.match(goalTreeModule, /GoalTreeIconPicker/);
assert.match(goalTreeModule, /GoalTreeContentEditor/);
assert.match(goalTreeModule, /GoalTreeNodeContentPreview/);
assert.doesNotMatch(goalTreeModule, /\.slice\(0, 3\)/);
assert.match(goalTreeModule, /GoalTreeChecklistInlineControl/);
assert.match(goalTreeModule, /GoalTreeStepLeafControl/);
assert.match(goalTreeModule, /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.match(goalTreeParts, /GoalTreeChecklistInlineControl/);
assert.match(goalTreeParts, /GoalTreeStepLeafControl/);
assert.match(goalTreeParts, /export function GoalTreePrerequisitePicker/);
assert.match(goalTreeModule, /function toggleBranch/);
assert.match(goalTreeModule, /function editNode/);
assert.match(goalTreeModule, /function addNodeContentItem/);
@ -387,12 +389,12 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(goalTreeModule, /module-scroll-button goal-tree-orientation-switch/);
assert.match(goalTreeModule, /Icon name="rotate"/);
assert.match(goalTreeModule, /function toggleLinkedParent/);
assert.match(goalTreeModule, /grouped: false/);
assert.match(goalTreeDisplay, /grouped: false/);
assert.match(goalTreeModule, /getGoalTreeConvergenceKeepOpenIds/);
assert.match(goalTreeModule, /progress\.current/);
assert.match(goalTreeModule, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
assert.match(goalTreeDisplay, /progress\.current/);
assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
assert.doesNotMatch(goalTreeUtils, /"calculator"/);
assert.match(goalTreeModule, /getItemProps\(\{ itemId: node\.id, parentId \}\)/);
assert.match(goalTreeParts, /getItemProps\(\{ itemId: node\.id, parentId \}\)/);
assert.match(goalTreeUtils, /export function calculateGoalTreeFormula/);
assert.match(goalTreeUtils, /export function evaluateGoalTreeNodes/);
assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/);

View file

@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { buildInsertedMixTagValue, normalizeMixTagSpacing, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../website/src/utils/mixTagsInputUtils.js";
import { getEffectiveGoalTreeProgressMode, getVisibleGoalTreeEdges } from "../website/src/features/toolboxes/modules/goalTreeDisplay.js";
import { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js";
import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
@ -1098,3 +1099,26 @@ test("goal tree parent progress uses base-scaled child bottlenecks", () => {
assert.equal(compact.nodes[4].progressMode, undefined);
assert.equal(compact.nodes[6].progressMode, "auto");
});
test("goal tree display helpers keep collapsed edges and progress modes stable", () => {
const nodes = evaluateGoalTreeNodes(normalizeGoalTreeData({
nodes: [
{ id: "root", title: "Root", kind: "step", qtyTarget: 1, collapsed: true },
{ id: "child", parentId: "root", title: "Child", kind: "step", qtyTarget: 1 },
{ id: "linked", parentId: "", parentIds: ["root"], title: "Linked", kind: "checklist", items: [{ id: "i1", label: "Line", qtyTarget: 1 }] },
{ id: "manualParent", title: "Manual Parent", kind: "step", qtyTarget: 1 },
{ id: "manualChild", parentId: "manualParent", title: "Manual Child", kind: "step", qtyTarget: 1 },
{ id: "autoParent", title: "Auto Parent", kind: "step", qtyTarget: 10 },
{ id: "autoChild", parentId: "autoParent", title: "Auto Child", kind: "step", qtyTarget: 5 }
]
}).nodes);
const edges = getVisibleGoalTreeEdges(nodes);
assert.deepEqual(edges, [
{ from: "manualParent", to: "manualChild" },
{ from: "autoParent", to: "autoChild" }
]);
assert.equal(edges.some((edge) => edge.from === "root" || edge.to === "child" || edge.to === "linked"), false);
assert.equal(getEffectiveGoalTreeProgressMode(nodes.find((node) => node.id === "manualParent"), [nodes.find((node) => node.id === "manualChild")]), "manual");
assert.equal(getEffectiveGoalTreeProgressMode(nodes.find((node) => node.id === "autoParent"), [nodes.find((node) => node.id === "autoChild")]), "auto");
});

View file

@ -1,145 +1,44 @@
// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
GoalTreeBranch,
GoalTreeContentEditor,
GoalTreeFormulaInput,
GoalTreeIconPicker,
GoalTreePrerequisitePicker,
GoalTreeTitleInput
} from "./GoalTreeParts.jsx";
import {
GOAL_TREE_VIEW_SCALE,
KIND_ICONS,
KIND_LABELS,
RESET_LABELS,
getDefaultGoalTreeItem,
getEffectiveGoalTreeProgressMode,
getGoalTreeConvergenceKeepOpenIds,
getGoalTreeLinkedControlIds,
getVisibleGoalTreeEdges,
isSingleValueGoalTreeNode
} from "./goalTreeDisplay.js";
import {
GOAL_TREE_COLORS,
GOAL_TREE_COLOR_VALUES,
GOAL_TREE_ICON_NAMES,
GOAL_TREE_KINDS,
GOAL_TREE_RESET_TYPES,
GOAL_TREE_WEEK_DAYS,
applyGoalTreeDueResets,
evaluateGoalTreeNodes,
formatGoalTreeNumber,
getGoalTreeChildren,
getGoalTreeDescendantIds,
getGoalTreeParentIds,
getGoalTreeParentMap
} from "./goalTreeUtils.js";
const KIND_LABELS = {
step: "Étape",
text: "Texte",
checklist: "Checklist"
};
const RESET_LABELS = {
none: "Aucun",
daily: "Quotidien",
weekly: "Hebdomadaire"
};
const KIND_ICONS = {
step: "diagram",
text: "notepad",
checklist: "checklist"
};
const GOAL_TREE_VIEW_SCALE = 0.7;
const BASE_TOKEN = "base";
function getGoalTreeKindIcon(kind) {
return KIND_ICONS[kind] || "diagram";
}
function getGoalTreeNodeIcon(node) {
return node.kind === "step" ? node.icon || "cube" : getGoalTreeKindIcon(node.kind);
}
function getGoalTreeNodeColor(node) {
return node.kind === "step" ? node.color || "gold" : "cyan";
}
function getDefaultGoalTreeItem(kind, context, textContent = {}, parentId = "") {
return {
id: context.uid("goalItem"),
label: textContent.itemLabelPlaceholder || "Élément",
formula: kind === "checklist" ? (parentId ? "base" : "1") : undefined,
qtyTarget: kind === "checklist" ? 1 : undefined,
qtyCurrent: kind === "checklist" ? 0 : undefined
};
}
function getGoalTreeNodeTooltip(node) {
return `${node.title} - ${formatGoalTreeNumber(node.progress.current)} / ${formatGoalTreeNumber(node.progress.target)}`;
}
function isSingleValueGoalTreeNode(node) {
return Math.max(1, node?.progress?.target || node?.computedQtyTarget || node?.qtyTarget || 1) <= 1;
}
function getEffectiveGoalTreeProgressMode(node, children = []) {
if (node?.kind !== "step") return "auto";
if (node.progressMode) return node.progressMode;
if (!children.length) return "manual";
return children.every(isSingleValueGoalTreeNode) ? "manual" : "auto";
}
function getVisibleGoalTreeNodeIds(nodes, parentId = "", ancestorVisible = true) {
if (!ancestorVisible) return new Set();
return getGoalTreeChildren(nodes, parentId).reduce((visibleIds, node) => {
visibleIds.add(node.id);
getVisibleGoalTreeNodeIds(nodes, node.id, !node.collapsed).forEach((nodeId) => visibleIds.add(nodeId));
return visibleIds;
}, new Set());
}
function getVisibleGoalTreeEdges(nodes) {
const visibleNodeIds = getVisibleGoalTreeNodeIds(nodes);
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const primaryEdges = nodes.flatMap((node) => {
if (!visibleNodeIds.has(node.id) || node.collapsed) return [];
const children = getGoalTreeChildren(nodes, node.id);
return children.filter((child) => visibleNodeIds.has(child.id)).map((child) => ({ from: node.id, to: child.id }));
});
const linkedEdges = nodes.flatMap((node) => getGoalTreeParentIds(node)
.filter((parentId) => parentId !== node.parentId)
.filter((parentId) => visibleNodeIds.has(parentId) && visibleNodeIds.has(node.id) && !nodeMap.get(parentId)?.collapsed)
.map((parentId) => ({ from: parentId, to: node.id, grouped: false })));
return [...primaryEdges, ...linkedEdges];
}
function getGoalTreeAncestorIds(nodes, nodeId) {
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const ancestors = new Set();
let current = nodeMap.get(nodeId);
while (current?.parentId && !ancestors.has(current.parentId)) {
ancestors.add(current.parentId);
current = nodeMap.get(current.parentId);
}
return ancestors;
}
function getGoalTreeConvergenceKeepOpenIds(nodes, targetId) {
const targetBranchIds = [targetId, ...getGoalTreeDescendantIds(nodes, targetId)];
const keepOpenIds = new Set();
targetBranchIds.forEach((nodeId) => {
const node = nodes.find((entry) => entry.id === nodeId);
getGoalTreeParentIds(node)
.filter((parentId) => parentId !== node?.parentId)
.forEach((parentId) => {
keepOpenIds.add(parentId);
getGoalTreeAncestorIds(nodes, parentId).forEach((ancestorId) => keepOpenIds.add(ancestorId));
});
});
return keepOpenIds;
}
function getGoalTreeLinkedControlIds(nodes, parentId) {
const controlledNodes = nodes.filter((node) => getGoalTreeParentIds(node).includes(parentId));
return new Set([
parentId,
...controlledNodes.flatMap((node) => getGoalTreeParentIds(node))
]);
}
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, textExchangeOpen }) {
const data = context.normalizeGoalTreeData(context.getModuleData(toolboxId, moduleId, { nodes: [] }));
const textContent = context.moduleText?.goalTree || {};
@ -650,419 +549,3 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
</div>
);
}
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>
);
}
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 = GOAL_TREE_COLOR_VALUES[getGoalTreeNodeColor(node)] || GOAL_TREE_COLOR_VALUES.gold;
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>
);
}
function GoalTreeTitleInput({ value, onChange }) {
const [draft, setDraft] = useState(value || "");
return (
<input
value={draft}
onChange={(event) => {
const title = event.target.value;
setDraft(title);
onChange(title);
}}
/>
);
}
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}
/>
);
}
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>
);
}
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>
);
}

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>
);
}

View file

@ -0,0 +1,128 @@
// Rôle : regroupe les libellés et helpers d'affichage purs de l'arbre d'objectifs.
import {
GOAL_TREE_COLOR_VALUES,
formatGoalTreeNumber,
getGoalTreeChildren,
getGoalTreeDescendantIds,
getGoalTreeParentIds
} from "./goalTreeUtils.js";
export const KIND_LABELS = {
step: "Étape",
text: "Texte",
checklist: "Checklist"
};
export const RESET_LABELS = {
none: "Aucun",
daily: "Quotidien",
weekly: "Hebdomadaire"
};
export const KIND_ICONS = {
step: "diagram",
text: "notepad",
checklist: "checklist"
};
export const GOAL_TREE_VIEW_SCALE = 0.7;
export const BASE_TOKEN = "base";
export function getGoalTreeKindIcon(kind) {
return KIND_ICONS[kind] || "diagram";
}
export function getGoalTreeNodeIcon(node) {
return node.kind === "step" ? node.icon || "cube" : getGoalTreeKindIcon(node.kind);
}
export function getGoalTreeNodeColor(node) {
return node.kind === "step" ? node.color || "gold" : "cyan";
}
export function getGoalTreeNodeIconColor(node) {
return GOAL_TREE_COLOR_VALUES[getGoalTreeNodeColor(node)] || GOAL_TREE_COLOR_VALUES.gold;
}
export function getDefaultGoalTreeItem(kind, context, textContent = {}, parentId = "") {
return {
id: context.uid("goalItem"),
label: textContent.itemLabelPlaceholder || "Élément",
formula: kind === "checklist" ? (parentId ? BASE_TOKEN : "1") : undefined,
qtyTarget: kind === "checklist" ? 1 : undefined,
qtyCurrent: kind === "checklist" ? 0 : undefined
};
}
export function getGoalTreeNodeTooltip(node) {
return `${node.title} - ${formatGoalTreeNumber(node.progress.current)} / ${formatGoalTreeNumber(node.progress.target)}`;
}
export function isSingleValueGoalTreeNode(node) {
return Math.max(1, node?.progress?.target || node?.computedQtyTarget || node?.qtyTarget || 1) <= 1;
}
export function getEffectiveGoalTreeProgressMode(node, children = []) {
if (node?.kind !== "step") return "auto";
if (node.progressMode) return node.progressMode;
if (!children.length) return "manual";
return children.every(isSingleValueGoalTreeNode) ? "manual" : "auto";
}
export function getVisibleGoalTreeNodeIds(nodes, parentId = "", ancestorVisible = true) {
if (!ancestorVisible) return new Set();
return getGoalTreeChildren(nodes, parentId).reduce((visibleIds, node) => {
visibleIds.add(node.id);
getVisibleGoalTreeNodeIds(nodes, node.id, !node.collapsed).forEach((nodeId) => visibleIds.add(nodeId));
return visibleIds;
}, new Set());
}
export function getVisibleGoalTreeEdges(nodes) {
const visibleNodeIds = getVisibleGoalTreeNodeIds(nodes);
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const primaryEdges = nodes.flatMap((node) => {
if (!visibleNodeIds.has(node.id) || node.collapsed) return [];
const children = getGoalTreeChildren(nodes, node.id);
return children.filter((child) => visibleNodeIds.has(child.id)).map((child) => ({ from: node.id, to: child.id }));
});
const linkedEdges = nodes.flatMap((node) => getGoalTreeParentIds(node)
.filter((parentId) => parentId !== node.parentId)
.filter((parentId) => visibleNodeIds.has(parentId) && visibleNodeIds.has(node.id) && !nodeMap.get(parentId)?.collapsed)
.map((parentId) => ({ from: parentId, to: node.id, grouped: false })));
return [...primaryEdges, ...linkedEdges];
}
export function getGoalTreeAncestorIds(nodes, nodeId) {
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const ancestors = new Set();
let current = nodeMap.get(nodeId);
while (current?.parentId && !ancestors.has(current.parentId)) {
ancestors.add(current.parentId);
current = nodeMap.get(current.parentId);
}
return ancestors;
}
export function getGoalTreeConvergenceKeepOpenIds(nodes, targetId) {
const targetBranchIds = [targetId, ...getGoalTreeDescendantIds(nodes, targetId)];
const keepOpenIds = new Set();
targetBranchIds.forEach((nodeId) => {
const node = nodes.find((entry) => entry.id === nodeId);
getGoalTreeParentIds(node)
.filter((parentId) => parentId !== node?.parentId)
.forEach((parentId) => {
keepOpenIds.add(parentId);
getGoalTreeAncestorIds(nodes, parentId).forEach((ancestorId) => keepOpenIds.add(ancestorId));
});
});
return keepOpenIds;
}
export function getGoalTreeLinkedControlIds(nodes, parentId) {
const controlledNodes = nodes.filter((node) => getGoalTreeParentIds(node).includes(parentId));
return new Set([
parentId,
...controlledNodes.flatMap((node) => getGoalTreeParentIds(node))
]);
}

View file

@ -1,5 +1,5 @@
// Rôle : synchronise les toolboxes, données d'outils, liens de jeux et quota via IndexedDB.
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getAllModuleData as dbGetAllModuleData,
getLinks as dbGetLinks,
@ -27,8 +27,42 @@ export function useIndexedToolboxes(onError) {
const [moduleData, setModuleDataState] = useState({});
const [toastPosition, setToastPositionState] = useState("right");
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
const toolboxesRef = useRef(toolboxes);
const linksRef = useRef(links);
const moduleDataRef = useRef(moduleData);
const toastPositionRef = useRef(toastPosition);
const writeVersionsRef = useRef({
toolboxes: 0,
links: 0,
moduleData: 0,
toastPosition: 0
});
async function refreshQuota() {
const reportStorageError = useCallback((error) => {
onError(error?.message || "Erreur de stockage IndexedDB.");
}, [onError]);
function commitToolboxes(nextToolboxes) {
toolboxesRef.current = nextToolboxes;
setToolboxesState(nextToolboxes);
}
function commitLinks(nextLinks) {
linksRef.current = nextLinks;
setLinksState(nextLinks);
}
function commitModuleData(nextModuleData) {
moduleDataRef.current = nextModuleData;
setModuleDataState(nextModuleData);
}
function commitToastPosition(nextToastPosition) {
toastPositionRef.current = nextToastPosition;
setToastPositionState(nextToastPosition);
}
const refreshQuota = useCallback(async () => {
try {
const estimate = await getStorageEstimate();
const used = estimate.usage || 0;
@ -37,7 +71,7 @@ export function useIndexedToolboxes(onError) {
} catch {
setStorageUsage({ used: 0, limit: 0, ratio: 0 });
}
}
}, []);
useEffect(() => {
let cancelled = false;
@ -50,85 +84,110 @@ export function useIndexedToolboxes(onError) {
dbGetSetting("toastPosition", "right")
]);
if (cancelled) return;
setToolboxesState((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
setLinksState(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
setModuleDataState(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])));
setToastPositionState(storedToastPosition === "left" ? "left" : "right");
commitToolboxes((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
commitLinks(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
commitModuleData(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])));
commitToastPosition(storedToastPosition === "left" ? "left" : "right");
setReady(true);
refreshQuota();
} catch (error) {
if (!cancelled) {
setReady(true);
onError(error.message || "Stockage IndexedDB indisponible.");
reportStorageError(error);
}
}
}
loadStore();
return () => { cancelled = true; };
}, [onError]);
}, [refreshQuota, reportStorageError]);
function persistToolboxes(nextToolboxes) {
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
setToolboxesState(normalized);
const previousToolboxes = toolboxesRef.current;
const version = ++writeVersionsRef.current.toolboxes;
commitToolboxes(normalized);
dbSetToolboxes(compactToolboxesForStorage(normalized))
.then(refreshQuota)
.catch((error) => onError(error.message));
.catch((error) => {
if (writeVersionsRef.current.toolboxes === version) commitToolboxes(previousToolboxes);
reportStorageError(error);
});
return true;
}
function persistLinks(nextLinks) {
setLinksState(nextLinks);
const previousLinks = linksRef.current;
const version = ++writeVersionsRef.current.links;
commitLinks(nextLinks);
dbSetLinks(nextLinks)
.then(refreshQuota)
.catch((error) => onError(error.message));
.catch((error) => {
if (writeVersionsRef.current.links === version) commitLinks(previousLinks);
reportStorageError(error);
});
return true;
}
function getModuleData(toolboxId, moduleId, fallback) {
const value = moduleData[moduleStorageKey(toolboxId, moduleId)];
const value = moduleDataRef.current[moduleStorageKey(toolboxId, moduleId)];
return value == null ? fallback : value;
}
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
const compact = prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType);
const compact = prepareModuleDataForStorage(toolboxesRef.current, toolboxId, moduleId, value, moduleType);
const key = moduleStorageKey(toolboxId, moduleId);
setModuleDataState((state) => {
const nextState = { ...state };
if (compact == null) delete nextState[key];
else nextState[key] = compact;
return nextState;
});
const previousModuleData = moduleDataRef.current;
const version = ++writeVersionsRef.current.moduleData;
const nextModuleData = { ...previousModuleData };
if (compact == null) delete nextModuleData[key];
else nextModuleData[key] = compact;
commitModuleData(nextModuleData);
const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact);
operation.then(refreshQuota).catch((error) => onError(error.message));
operation.then(refreshQuota).catch((error) => {
if (writeVersionsRef.current.moduleData === version) commitModuleData(previousModuleData);
reportStorageError(error);
});
return true;
}
function removeModuleData(toolboxId, moduleId) {
const key = moduleStorageKey(toolboxId, moduleId);
setModuleDataState((state) => {
const nextState = { ...state };
delete nextState[key];
return nextState;
const previousModuleData = moduleDataRef.current;
const version = ++writeVersionsRef.current.moduleData;
const nextModuleData = { ...previousModuleData };
delete nextModuleData[key];
commitModuleData(nextModuleData);
dbRemoveModuleData(key).then(refreshQuota).catch((error) => {
if (writeVersionsRef.current.moduleData === version) commitModuleData(previousModuleData);
reportStorageError(error);
});
dbRemoveModuleData(key).then(refreshQuota).catch((error) => onError(error.message));
}
function removeToolboxModuleData(toolbox) {
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
setModuleDataState((state) => {
const nextState = { ...state };
keys.forEach((key) => delete nextState[key]);
return nextState;
if (!keys.length) return;
const previousModuleData = moduleDataRef.current;
const version = ++writeVersionsRef.current.moduleData;
const nextModuleData = { ...previousModuleData };
keys.forEach((key) => delete nextModuleData[key]);
commitModuleData(nextModuleData);
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => {
if (writeVersionsRef.current.moduleData === version) commitModuleData(previousModuleData);
reportStorageError(error);
});
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => onError(error.message));
}
function setToastPosition(position) {
const normalized = position === "left" ? "left" : "right";
setToastPositionState(normalized);
const previousToastPosition = toastPositionRef.current;
const version = ++writeVersionsRef.current.toastPosition;
commitToastPosition(normalized);
dbSetSetting("toastPosition", normalized)
.then(refreshQuota)
.catch((error) => onError(error.message));
.catch((error) => {
if (writeVersionsRef.current.toastPosition === version) commitToastPosition(previousToastPosition);
reportStorageError(error);
});
}
return {

View file

@ -23,18 +23,25 @@ function openDatabase() {
return dbPromise;
}
function requestToPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function storeRequest(storeName, mode, callback) {
const db = await openDatabase();
const transaction = db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
return requestToPromise(callback(store));
let result;
await new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
try {
const request = callback(store);
request.onsuccess = () => { result = request.result; };
request.onerror = () => reject(request.error);
} catch (error) {
transaction.abort();
reject(error);
}
});
return result;
}
export async function getValue(key, fallback) {