diff --git a/e2e/fixtures/toolbox-export.json b/e2e/fixtures/toolbox-export.json new file mode 100644 index 0000000..2d2699b --- /dev/null +++ b/e2e/fixtures/toolbox-export.json @@ -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": "

Imported fixture note

", + "text": "Imported fixture note", + "updatedAt": "2026-08-24T10:00:00.000Z" + } + } +} diff --git a/e2e/helpers/storage.js b/e2e/helpers/storage.js new file mode 100644 index 0000000..9b45c19 --- /dev/null +++ b/e2e/helpers/storage.js @@ -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); + }; + }); + }); +} diff --git a/e2e/toolbox.spec.js b/e2e/toolbox.spec.js new file mode 100644 index 0000000..d0f2991 --- /dev/null +++ b/e2e/toolbox.spec.js @@ -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"); +}); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 46c6096..2f7f18a 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -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/); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 38416e6..b499166 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -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"); +}); diff --git a/website/src/features/toolboxes/modules/GoalTreeModule.jsx b/website/src/features/toolboxes/modules/GoalTreeModule.jsx index 0923d03..4469b54 100644 --- a/website/src/features/toolboxes/modules/GoalTreeModule.jsx +++ b/website/src/features/toolboxes/modules/GoalTreeModule.jsx @@ -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 ); } - -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 ( -
- {textContent.prerequisitesSectionTitle || "Prérequis supplémentaires"} -
- {primaryParent && ( - - )} - {options.map((candidate) => ( - - ))} -
-
- ); -} - -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 ( - - ); -} - -function GoalTreeTitleInput({ value, onChange }) { - const [draft, setDraft] = useState(value || ""); - return ( - { - 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 ( - onChange(event.target.value.replaceAll(",", "."))} - placeholder={placeholder} - title={title} - value={normalizedValue} - /> - ); - } - - return ( - 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 ( -
-
{textContent.contentSectionTitle || "Contenu"}
- {(node.items || []).map((item) => ( -
- onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))} - aria-label={textContent.itemLabelLabel || "Libellé"} - /> - {node.kind === "checklist" && ( - 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."} - /> - )} - -
- ))} - -
- ); -} - -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 ( - = 1} - onChange={(event) => onUpdate((entry) => ({ ...entry, qtyCurrent: event.target.checked ? 1 : 0 }))} - aria-label={textContent.itemCheckedLabel || "État"} - /> - ); - } - return ( - - - {formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)} - - - ); -} - -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 ( - = 1} - onChange={(event) => onChange(event.target.checked ? 1 : 0)} - aria-label={textContent.itemCheckedLabel || "État"} - /> - ); - } - return ( - - - {formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)} - - - ); -} - -function GoalTreeNodeContentPreview({ node, editing, textContent, onAddContentItem, onUpdateItem }) { - const items = node.items || []; - return ( - - {items.map((item) => ( - - {node.kind === "checklist" && onUpdateItem(item.id, updater)} />} - - {item.label} - - - ))} - {editing && ( - - - - )} - - ); -} - -function GoalTreeIconPicker({ label, value, textContent, onChange }) { - return ( -
- {label} - ( - - )} - > - {({ close }) => ( - <> - {GOAL_TREE_ICON_NAMES.map((icon) => ( - - ))} - - )} - -
- ); -} diff --git a/website/src/features/toolboxes/modules/GoalTreeParts.jsx b/website/src/features/toolboxes/modules/GoalTreeParts.jsx new file mode 100644 index 0000000..76bdbb3 --- /dev/null +++ b/website/src/features/toolboxes/modules/GoalTreeParts.jsx @@ -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 ( +
+ {textContent.prerequisitesSectionTitle || "Prérequis supplémentaires"} +
+ {primaryParent && ( + + )} + {options.map((candidate) => ( + + ))} +
+
+ ); +} + +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 ( + + ); +} + +export function GoalTreeTitleInput({ value, onChange }) { + const [draft, setDraft] = useState(value || ""); + return ( + { + 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 onChange(event.target.value.replaceAll(",", "."))} placeholder={placeholder} title={title} value={normalizedValue} />; + } + + return ( + 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 ( +
+
{textContent.contentSectionTitle || "Contenu"}
+ {(node.items || []).map((item) => ( +
+ onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))} aria-label={textContent.itemLabelLabel || "Libellé"} /> + {node.kind === "checklist" && ( + 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."} + /> + )} + +
+ ))} + +
+ ); +} + +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 = 1} onChange={(event) => onUpdate((entry) => ({ ...entry, qtyCurrent: event.target.checked ? 1 : 0 }))} aria-label={textContent.itemCheckedLabel || "État"} />; + } + return ( + + + {formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)} + + + ); +} + +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 = 1} onChange={(event) => onChange(event.target.checked ? 1 : 0)} aria-label={textContent.itemCheckedLabel || "État"} />; + } + return ( + + + {formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)} + + + ); +} + +function GoalTreeNodeContentPreview({ node, editing, textContent, onAddContentItem, onUpdateItem }) { + const items = node.items || []; + return ( + + {items.map((item) => ( + + {node.kind === "checklist" && onUpdateItem(item.id, updater)} />} + + {item.label} + + + ))} + {editing && ( + + + + )} + + ); +} + +export function GoalTreeIconPicker({ label, value, textContent, onChange }) { + return ( +
+ {label} + ( + + )} + > + {({ close }) => ( + <> + {GOAL_TREE_ICON_NAMES.map((icon) => ( + + ))} + + )} + +
+ ); +} diff --git a/website/src/features/toolboxes/modules/goalTreeDisplay.js b/website/src/features/toolboxes/modules/goalTreeDisplay.js new file mode 100644 index 0000000..82fea37 --- /dev/null +++ b/website/src/features/toolboxes/modules/goalTreeDisplay.js @@ -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)) + ]); +} diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js index 4e11837..8cef150 100644 --- a/website/src/features/toolboxes/storage/useIndexedToolboxes.js +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -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 { diff --git a/website/src/utils/indexedDbStorage.js b/website/src/utils/indexedDbStorage.js index 49fa21a..f0eebec 100644 --- a/website/src/utils/indexedDbStorage.js +++ b/website/src/utils/indexedDbStorage.js @@ -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) {