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 (
-
- );
-}
-
-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 (
-
- {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 (
- -
-
- {!editing && (
-
-
-
-
- )}
- {editing && (
-
- )}
-
- {node.kind === "step" ? (
- <>
-
-
-
- {(manualProgress || !nestedChildren.length) && (
- onSetStepLeafQuantity(node.id, quantity)} />
- )}
- {!manualProgress && hasSingleValueChildren && (
- onSetStepChildrenCompletion(node.id, quantity > 0)}
- />
- )}
- >
- ) : (
- <>
-
-
-
-
- {node.title}
-
- onAddContentItem(node.id)}
- onUpdateItem={(itemId, updater) => onUpdateNodeItem(node.id, itemId, updater)}
- />
- >
- )}
-
-
-
-
- {hasNodeActions && (
-
- {editing && (
-
- )}
- {hasBranchToggle && (
-
- )}
- {editing && (
-
- )}
-
- )}
-
- {!node.collapsed && nestedChildren.length > 0 && (
-
- )}
-
- );
- })}
-
- );
-}
-
-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 (
+
+ );
+}
+
+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 (
+
+ {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 (
+ -
+
+ {!editing && (
+
+
+
+
+ )}
+ {editing && (
+
+ )}
+
+ {node.kind === "step" ? (
+ <>
+
+
+
+ {(manualProgress || !nestedChildren.length) && (
+ onSetStepLeafQuantity(node.id, quantity)} />
+ )}
+ {!manualProgress && hasSingleValueChildren && (
+ onSetStepChildrenCompletion(node.id, quantity > 0)}
+ />
+ )}
+ >
+ ) : (
+ <>
+
+
+
+
+ {node.title}
+
+ onAddContentItem(node.id)}
+ onUpdateItem={(itemId, updater) => onUpdateNodeItem(node.id, itemId, updater)}
+ />
+ >
+ )}
+
+
+
+
+ {hasNodeActions && (
+
+ {editing && (
+
+ )}
+ {hasBranchToggle && (
+
+ )}
+ {editing && (
+
+ )}
+
+ )}
+
+ {!node.collapsed && nestedChildren.length > 0 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+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) {