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 taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
|
||||||
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "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 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 goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.js", "utf8");
|
||||||
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "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");
|
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, /applyGoalTreeDueResets/);
|
||||||
assert.match(goalTreeModule, /TextExchangeActions/);
|
assert.match(goalTreeModule, /TextExchangeActions/);
|
||||||
assert.match(goalTreeModule, /DiagramConnectors/);
|
assert.match(goalTreeModule, /DiagramConnectors/);
|
||||||
assert.match(goalTreeModule, /Tooltip/);
|
assert.match(goalTreeParts, /Tooltip/);
|
||||||
assert.match(goalTreeModule, /GoalTreeIconPicker/);
|
assert.match(goalTreeParts, /export function GoalTreeIconPicker/);
|
||||||
assert.match(goalTreeModule, /GoalTreeContentEditor/);
|
assert.match(goalTreeParts, /export function GoalTreeContentEditor/);
|
||||||
assert.match(goalTreeModule, /GoalTreeNodeContentPreview/);
|
assert.match(goalTreeParts, /function GoalTreeNodeContentPreview/);
|
||||||
assert.doesNotMatch(goalTreeModule, /\.slice\(0, 3\)/);
|
assert.doesNotMatch(`${goalTreeModule}\n${goalTreeParts}`, /\.slice\(0, 3\)/);
|
||||||
assert.match(goalTreeModule, /GoalTreeChecklistInlineControl/);
|
assert.match(goalTreeParts, /GoalTreeChecklistInlineControl/);
|
||||||
assert.match(goalTreeModule, /GoalTreeStepLeafControl/);
|
assert.match(goalTreeParts, /GoalTreeStepLeafControl/);
|
||||||
assert.match(goalTreeModule, /GoalTreePrerequisitePicker/);
|
assert.match(goalTreeParts, /export function GoalTreePrerequisitePicker/);
|
||||||
assert.match(goalTreeModule, /function toggleBranch/);
|
assert.match(goalTreeModule, /function toggleBranch/);
|
||||||
assert.match(goalTreeModule, /function editNode/);
|
assert.match(goalTreeModule, /function editNode/);
|
||||||
assert.match(goalTreeModule, /function addNodeContentItem/);
|
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, /module-scroll-button goal-tree-orientation-switch/);
|
||||||
assert.match(goalTreeModule, /Icon name="rotate"/);
|
assert.match(goalTreeModule, /Icon name="rotate"/);
|
||||||
assert.match(goalTreeModule, /function toggleLinkedParent/);
|
assert.match(goalTreeModule, /function toggleLinkedParent/);
|
||||||
assert.match(goalTreeModule, /grouped: false/);
|
assert.match(goalTreeDisplay, /grouped: false/);
|
||||||
assert.match(goalTreeModule, /getGoalTreeConvergenceKeepOpenIds/);
|
assert.match(goalTreeModule, /getGoalTreeConvergenceKeepOpenIds/);
|
||||||
assert.match(goalTreeModule, /progress\.current/);
|
assert.match(goalTreeDisplay, /progress\.current/);
|
||||||
assert.match(goalTreeModule, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
|
assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
|
||||||
assert.doesNotMatch(goalTreeUtils, /"calculator"/);
|
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 calculateGoalTreeFormula/);
|
||||||
assert.match(goalTreeUtils, /export function evaluateGoalTreeNodes/);
|
assert.match(goalTreeUtils, /export function evaluateGoalTreeNodes/);
|
||||||
assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/);
|
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 { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
|
||||||
import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.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 { 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 { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js";
|
||||||
import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.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";
|
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[4].progressMode, undefined);
|
||||||
assert.equal(compact.nodes[6].progressMode, "auto");
|
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.
|
// 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
|
||||||
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
|
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
|
|
||||||
import { Tabs } from "../../../components/Tabs.jsx";
|
import { Tabs } from "../../../components/Tabs.jsx";
|
||||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
|
||||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
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 {
|
import {
|
||||||
GOAL_TREE_COLORS,
|
GOAL_TREE_COLORS,
|
||||||
GOAL_TREE_COLOR_VALUES,
|
GOAL_TREE_COLOR_VALUES,
|
||||||
GOAL_TREE_ICON_NAMES,
|
|
||||||
GOAL_TREE_KINDS,
|
GOAL_TREE_KINDS,
|
||||||
GOAL_TREE_RESET_TYPES,
|
GOAL_TREE_RESET_TYPES,
|
||||||
GOAL_TREE_WEEK_DAYS,
|
GOAL_TREE_WEEK_DAYS,
|
||||||
applyGoalTreeDueResets,
|
applyGoalTreeDueResets,
|
||||||
evaluateGoalTreeNodes,
|
evaluateGoalTreeNodes,
|
||||||
formatGoalTreeNumber,
|
|
||||||
getGoalTreeChildren,
|
getGoalTreeChildren,
|
||||||
getGoalTreeDescendantIds,
|
getGoalTreeDescendantIds,
|
||||||
getGoalTreeParentIds,
|
getGoalTreeParentIds,
|
||||||
getGoalTreeParentMap
|
getGoalTreeParentMap
|
||||||
} from "./goalTreeUtils.js";
|
} 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 }) {
|
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, textExchangeOpen }) {
|
||||||
const data = context.normalizeGoalTreeData(context.getModuleData(toolboxId, moduleId, { nodes: [] }));
|
const data = context.normalizeGoalTreeData(context.getModuleData(toolboxId, moduleId, { nodes: [] }));
|
||||||
const textContent = context.moduleText?.goalTree || {};
|
const textContent = context.moduleText?.goalTree || {};
|
||||||
|
|
@ -650,419 +549,3 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
|
||||||
</div>
|
</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.
|
// 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 {
|
import {
|
||||||
getAllModuleData as dbGetAllModuleData,
|
getAllModuleData as dbGetAllModuleData,
|
||||||
getLinks as dbGetLinks,
|
getLinks as dbGetLinks,
|
||||||
|
|
@ -27,8 +27,42 @@ export function useIndexedToolboxes(onError) {
|
||||||
const [moduleData, setModuleDataState] = useState({});
|
const [moduleData, setModuleDataState] = useState({});
|
||||||
const [toastPosition, setToastPositionState] = useState("right");
|
const [toastPosition, setToastPositionState] = useState("right");
|
||||||
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
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 {
|
try {
|
||||||
const estimate = await getStorageEstimate();
|
const estimate = await getStorageEstimate();
|
||||||
const used = estimate.usage || 0;
|
const used = estimate.usage || 0;
|
||||||
|
|
@ -37,7 +71,7 @@ export function useIndexedToolboxes(onError) {
|
||||||
} catch {
|
} catch {
|
||||||
setStorageUsage({ used: 0, limit: 0, ratio: 0 });
|
setStorageUsage({ used: 0, limit: 0, ratio: 0 });
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -50,85 +84,110 @@ export function useIndexedToolboxes(onError) {
|
||||||
dbGetSetting("toastPosition", "right")
|
dbGetSetting("toastPosition", "right")
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setToolboxesState((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
|
commitToolboxes((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
|
||||||
setLinksState(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
|
commitLinks(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
|
||||||
setModuleDataState(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])));
|
commitModuleData(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])));
|
||||||
setToastPositionState(storedToastPosition === "left" ? "left" : "right");
|
commitToastPosition(storedToastPosition === "left" ? "left" : "right");
|
||||||
setReady(true);
|
setReady(true);
|
||||||
refreshQuota();
|
refreshQuota();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setReady(true);
|
setReady(true);
|
||||||
onError(error.message || "Stockage IndexedDB indisponible.");
|
reportStorageError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadStore();
|
loadStore();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [onError]);
|
}, [refreshQuota, reportStorageError]);
|
||||||
|
|
||||||
function persistToolboxes(nextToolboxes) {
|
function persistToolboxes(nextToolboxes) {
|
||||||
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
|
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
|
||||||
setToolboxesState(normalized);
|
const previousToolboxes = toolboxesRef.current;
|
||||||
|
const version = ++writeVersionsRef.current.toolboxes;
|
||||||
|
commitToolboxes(normalized);
|
||||||
dbSetToolboxes(compactToolboxesForStorage(normalized))
|
dbSetToolboxes(compactToolboxesForStorage(normalized))
|
||||||
.then(refreshQuota)
|
.then(refreshQuota)
|
||||||
.catch((error) => onError(error.message));
|
.catch((error) => {
|
||||||
|
if (writeVersionsRef.current.toolboxes === version) commitToolboxes(previousToolboxes);
|
||||||
|
reportStorageError(error);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistLinks(nextLinks) {
|
function persistLinks(nextLinks) {
|
||||||
setLinksState(nextLinks);
|
const previousLinks = linksRef.current;
|
||||||
|
const version = ++writeVersionsRef.current.links;
|
||||||
|
commitLinks(nextLinks);
|
||||||
dbSetLinks(nextLinks)
|
dbSetLinks(nextLinks)
|
||||||
.then(refreshQuota)
|
.then(refreshQuota)
|
||||||
.catch((error) => onError(error.message));
|
.catch((error) => {
|
||||||
|
if (writeVersionsRef.current.links === version) commitLinks(previousLinks);
|
||||||
|
reportStorageError(error);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModuleData(toolboxId, moduleId, fallback) {
|
function getModuleData(toolboxId, moduleId, fallback) {
|
||||||
const value = moduleData[moduleStorageKey(toolboxId, moduleId)];
|
const value = moduleDataRef.current[moduleStorageKey(toolboxId, moduleId)];
|
||||||
return value == null ? fallback : value;
|
return value == null ? fallback : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
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);
|
const key = moduleStorageKey(toolboxId, moduleId);
|
||||||
setModuleDataState((state) => {
|
const previousModuleData = moduleDataRef.current;
|
||||||
const nextState = { ...state };
|
const version = ++writeVersionsRef.current.moduleData;
|
||||||
if (compact == null) delete nextState[key];
|
const nextModuleData = { ...previousModuleData };
|
||||||
else nextState[key] = compact;
|
if (compact == null) delete nextModuleData[key];
|
||||||
return nextState;
|
else nextModuleData[key] = compact;
|
||||||
});
|
commitModuleData(nextModuleData);
|
||||||
const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeModuleData(toolboxId, moduleId) {
|
function removeModuleData(toolboxId, moduleId) {
|
||||||
const key = moduleStorageKey(toolboxId, moduleId);
|
const key = moduleStorageKey(toolboxId, moduleId);
|
||||||
setModuleDataState((state) => {
|
const previousModuleData = moduleDataRef.current;
|
||||||
const nextState = { ...state };
|
const version = ++writeVersionsRef.current.moduleData;
|
||||||
delete nextState[key];
|
const nextModuleData = { ...previousModuleData };
|
||||||
return nextState;
|
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) {
|
function removeToolboxModuleData(toolbox) {
|
||||||
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
|
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
|
||||||
setModuleDataState((state) => {
|
if (!keys.length) return;
|
||||||
const nextState = { ...state };
|
const previousModuleData = moduleDataRef.current;
|
||||||
keys.forEach((key) => delete nextState[key]);
|
const version = ++writeVersionsRef.current.moduleData;
|
||||||
return nextState;
|
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) {
|
function setToastPosition(position) {
|
||||||
const normalized = position === "left" ? "left" : "right";
|
const normalized = position === "left" ? "left" : "right";
|
||||||
setToastPositionState(normalized);
|
const previousToastPosition = toastPositionRef.current;
|
||||||
|
const version = ++writeVersionsRef.current.toastPosition;
|
||||||
|
commitToastPosition(normalized);
|
||||||
dbSetSetting("toastPosition", normalized)
|
dbSetSetting("toastPosition", normalized)
|
||||||
.then(refreshQuota)
|
.then(refreshQuota)
|
||||||
.catch((error) => onError(error.message));
|
.catch((error) => {
|
||||||
|
if (writeVersionsRef.current.toastPosition === version) commitToastPosition(previousToastPosition);
|
||||||
|
reportStorageError(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,25 @@ function openDatabase() {
|
||||||
return dbPromise;
|
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) {
|
async function storeRequest(storeName, mode, callback) {
|
||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
const transaction = db.transaction(storeName, mode);
|
const transaction = db.transaction(storeName, mode);
|
||||||
const store = transaction.objectStore(storeName);
|
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) {
|
export async function getValue(key, fallback) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue