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