Improve toolbox persistence and goal tree coverage
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 16:11:22 +02:00
parent 50029b45a2
commit 7348969ac4
10 changed files with 801 additions and 591 deletions

View file

@ -1,145 +1,44 @@
// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
GoalTreeBranch,
GoalTreeContentEditor,
GoalTreeFormulaInput,
GoalTreeIconPicker,
GoalTreePrerequisitePicker,
GoalTreeTitleInput
} from "./GoalTreeParts.jsx";
import {
GOAL_TREE_VIEW_SCALE,
KIND_ICONS,
KIND_LABELS,
RESET_LABELS,
getDefaultGoalTreeItem,
getEffectiveGoalTreeProgressMode,
getGoalTreeConvergenceKeepOpenIds,
getGoalTreeLinkedControlIds,
getVisibleGoalTreeEdges,
isSingleValueGoalTreeNode
} from "./goalTreeDisplay.js";
import {
GOAL_TREE_COLORS,
GOAL_TREE_COLOR_VALUES,
GOAL_TREE_ICON_NAMES,
GOAL_TREE_KINDS,
GOAL_TREE_RESET_TYPES,
GOAL_TREE_WEEK_DAYS,
applyGoalTreeDueResets,
evaluateGoalTreeNodes,
formatGoalTreeNumber,
getGoalTreeChildren,
getGoalTreeDescendantIds,
getGoalTreeParentIds,
getGoalTreeParentMap
} from "./goalTreeUtils.js";
const KIND_LABELS = {
step: "Étape",
text: "Texte",
checklist: "Checklist"
};
const RESET_LABELS = {
none: "Aucun",
daily: "Quotidien",
weekly: "Hebdomadaire"
};
const KIND_ICONS = {
step: "diagram",
text: "notepad",
checklist: "checklist"
};
const GOAL_TREE_VIEW_SCALE = 0.7;
const BASE_TOKEN = "base";
function getGoalTreeKindIcon(kind) {
return KIND_ICONS[kind] || "diagram";
}
function getGoalTreeNodeIcon(node) {
return node.kind === "step" ? node.icon || "cube" : getGoalTreeKindIcon(node.kind);
}
function getGoalTreeNodeColor(node) {
return node.kind === "step" ? node.color || "gold" : "cyan";
}
function getDefaultGoalTreeItem(kind, context, textContent = {}, parentId = "") {
return {
id: context.uid("goalItem"),
label: textContent.itemLabelPlaceholder || "Élément",
formula: kind === "checklist" ? (parentId ? "base" : "1") : undefined,
qtyTarget: kind === "checklist" ? 1 : undefined,
qtyCurrent: kind === "checklist" ? 0 : undefined
};
}
function getGoalTreeNodeTooltip(node) {
return `${node.title} - ${formatGoalTreeNumber(node.progress.current)} / ${formatGoalTreeNumber(node.progress.target)}`;
}
function isSingleValueGoalTreeNode(node) {
return Math.max(1, node?.progress?.target || node?.computedQtyTarget || node?.qtyTarget || 1) <= 1;
}
function getEffectiveGoalTreeProgressMode(node, children = []) {
if (node?.kind !== "step") return "auto";
if (node.progressMode) return node.progressMode;
if (!children.length) return "manual";
return children.every(isSingleValueGoalTreeNode) ? "manual" : "auto";
}
function getVisibleGoalTreeNodeIds(nodes, parentId = "", ancestorVisible = true) {
if (!ancestorVisible) return new Set();
return getGoalTreeChildren(nodes, parentId).reduce((visibleIds, node) => {
visibleIds.add(node.id);
getVisibleGoalTreeNodeIds(nodes, node.id, !node.collapsed).forEach((nodeId) => visibleIds.add(nodeId));
return visibleIds;
}, new Set());
}
function getVisibleGoalTreeEdges(nodes) {
const visibleNodeIds = getVisibleGoalTreeNodeIds(nodes);
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const primaryEdges = nodes.flatMap((node) => {
if (!visibleNodeIds.has(node.id) || node.collapsed) return [];
const children = getGoalTreeChildren(nodes, node.id);
return children.filter((child) => visibleNodeIds.has(child.id)).map((child) => ({ from: node.id, to: child.id }));
});
const linkedEdges = nodes.flatMap((node) => getGoalTreeParentIds(node)
.filter((parentId) => parentId !== node.parentId)
.filter((parentId) => visibleNodeIds.has(parentId) && visibleNodeIds.has(node.id) && !nodeMap.get(parentId)?.collapsed)
.map((parentId) => ({ from: parentId, to: node.id, grouped: false })));
return [...primaryEdges, ...linkedEdges];
}
function getGoalTreeAncestorIds(nodes, nodeId) {
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const ancestors = new Set();
let current = nodeMap.get(nodeId);
while (current?.parentId && !ancestors.has(current.parentId)) {
ancestors.add(current.parentId);
current = nodeMap.get(current.parentId);
}
return ancestors;
}
function getGoalTreeConvergenceKeepOpenIds(nodes, targetId) {
const targetBranchIds = [targetId, ...getGoalTreeDescendantIds(nodes, targetId)];
const keepOpenIds = new Set();
targetBranchIds.forEach((nodeId) => {
const node = nodes.find((entry) => entry.id === nodeId);
getGoalTreeParentIds(node)
.filter((parentId) => parentId !== node?.parentId)
.forEach((parentId) => {
keepOpenIds.add(parentId);
getGoalTreeAncestorIds(nodes, parentId).forEach((ancestorId) => keepOpenIds.add(ancestorId));
});
});
return keepOpenIds;
}
function getGoalTreeLinkedControlIds(nodes, parentId) {
const controlledNodes = nodes.filter((node) => getGoalTreeParentIds(node).includes(parentId));
return new Set([
parentId,
...controlledNodes.flatMap((node) => getGoalTreeParentIds(node))
]);
}
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, textExchangeOpen }) {
const data = context.normalizeGoalTreeData(context.getModuleData(toolboxId, moduleId, { nodes: [] }));
const textContent = context.moduleText?.goalTree || {};
@ -650,419 +549,3 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
</div>
);
}
function GoalTreePrerequisitePicker({ nodes, node, textContent, onToggle }) {
const parentIds = new Set(getGoalTreeParentIds(node));
const blockedIds = new Set([node.id, ...getGoalTreeDescendantIds(nodes, node.id)]);
const primaryParent = nodes.find((candidate) => candidate.id === node.parentId);
const options = nodes.filter((candidate) => !blockedIds.has(candidate.id) && candidate.id !== node.parentId);
if (!primaryParent && !options.length) return null;
return (
<div className="goal-tree-prerequisite-field">
<span className="goal-tree-form-section-title">{textContent.prerequisitesSectionTitle || "Prérequis supplémentaires"}</span>
<div className="goal-tree-prerequisite-list themed-scrollbar">
{primaryParent && (
<label className="goal-tree-prerequisite-item is-primary">
<input
type="checkbox"
checked
disabled
readOnly
/>
<span>{primaryParent.title}</span>
</label>
)}
{options.map((candidate) => (
<label className="goal-tree-prerequisite-item" key={candidate.id}>
<input
type="checkbox"
checked={parentIds.has(candidate.id)}
onChange={() => onToggle(candidate.id)}
/>
<span>{candidate.title}</span>
</label>
))}
</div>
</div>
);
}
function GoalTreeBranch({ nodes, parentId, selectedNodeId, editing, reorder, textContent, onSelect, onAdd, onAddContentItem, onSetStepLeafQuantity, onSetStepChildrenCompletion, onUpdateNodeItem, onEditNode, onToggleBranch, onDeleteNode }) {
const children = getGoalTreeChildren(nodes, parentId);
if (!children.length) return null;
return (
<ul className={`goal-tree-list ${parentId ? "is-child-list" : "is-root-list"}`} data-goal-tree-parent={parentId || "root"}>
{children.map((node) => {
const nestedChildren = getGoalTreeChildren(nodes, node.id);
const linkedChildren = nodes.filter((candidate) => candidate.parentId !== node.id && getGoalTreeParentIds(candidate).includes(node.id));
const branchCollapsed = node.collapsed;
const isSelected = selectedNodeId === node.id;
const isComplete = node.progress.complete;
const hasSingleValueChildren = nestedChildren.length > 0 && nestedChildren.every(isSingleValueGoalTreeNode);
const manualProgress = getEffectiveGoalTreeProgressMode(node, nestedChildren) === "manual";
const hasBranchToggle = nestedChildren.length > 0 || linkedChildren.length > 0;
const hasNodeActions = editing || hasBranchToggle;
const iconColor = GOAL_TREE_COLOR_VALUES[getGoalTreeNodeColor(node)] || GOAL_TREE_COLOR_VALUES.gold;
const selectFromCard = (event) => {
if (!editing) return;
if (event.target.closest("button:not(.goal-tree-node-main)")) return;
onSelect(node.id);
};
const className = [
"goal-tree-node",
`is-${node.kind}`,
isSelected ? "is-selected" : "",
isComplete ? "is-complete" : "",
reorder.isItemDragging(node.id) ? "is-dragging" : "",
reorder.isItemDropTarget(node.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", node.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li
key={node.id}
className={className}
{...reorder.getItemProps({ itemId: node.id, parentId })}
>
<article className={`goal-tree-node-card ${editing ? "is-editing" : "is-viewing"} ${hasNodeActions ? "has-actions" : ""}`} data-diagram-node={node.id} onClick={selectFromCard}>
{!editing && (
<div className="goal-tree-node-hover-actions" aria-label={textContent.cardActionsLabel || "Actions de l'objectif"}>
<button type="button" onClick={() => onEditNode(node.id)} aria-label={`${textContent.editTitle || "Modifier"} ${node.title}`} title={textContent.editTitle || "Modifier"}>
<Icon name="edit" />
</button>
<button className="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
)}
{editing && (
<button
className="goal-tree-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, node.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${node.title}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
)}
<Tooltip
as="div"
className="goal-tree-node-main"
content={node.kind === "step" ? getGoalTreeNodeTooltip(node) : ""}
tooltipClassName="goal-tree-node-tooltip"
aria-label={node.title}
>
{node.kind === "step" ? (
<>
<span className="goal-tree-node-icon" aria-hidden="true" style={{ "--goal-tree-node-color": iconColor }}>
<Icon name={getGoalTreeNodeIcon(node)} />
</span>
{(manualProgress || !nestedChildren.length) && (
<GoalTreeStepLeafControl node={node} textContent={textContent} onChange={(quantity) => onSetStepLeafQuantity(node.id, quantity)} />
)}
{!manualProgress && hasSingleValueChildren && (
<GoalTreeStepLeafControl
node={{ ...node, computedQtyTarget: 1, qtyTarget: 1, qtyCurrent: node.progress.complete ? 1 : 0 }}
textContent={textContent}
onChange={(quantity) => onSetStepChildrenCompletion(node.id, quantity > 0)}
/>
)}
</>
) : (
<>
<span className="goal-tree-node-heading">
<span className="goal-tree-node-icon" aria-hidden="true" style={{ "--goal-tree-node-color": iconColor }}>
<Icon name={getGoalTreeNodeIcon(node)} />
</span>
<span className="goal-tree-node-title">{node.title}</span>
</span>
<GoalTreeNodeContentPreview
node={node}
editing={editing}
textContent={textContent}
onAddContentItem={() => onAddContentItem(node.id)}
onUpdateItem={(itemId, updater) => onUpdateNodeItem(node.id, itemId, updater)}
/>
</>
)}
<span className="goal-tree-progress-track" aria-hidden="true">
<span style={{ width: `${Math.round(node.progress.ratio * 100)}%` }} />
</span>
</Tooltip>
{hasNodeActions && (
<div className="goal-tree-node-actions">
{editing && (
<button type="button" onClick={() => onAdd(node.id)} aria-label={`${textContent.addChildTitle || "Ajouter une sous-étape"} ${node.title}`} title={textContent.addChildTitle || "Ajouter une sous-étape"}>
<Icon name="add" />
</button>
)}
{hasBranchToggle && (
<button
className="goal-tree-branch-toggle"
type="button"
onClick={() => onToggleBranch(node.id)}
aria-expanded={!branchCollapsed}
aria-label={branchCollapsed ? textContent.showChildrenTitle || "Afficher la branche" : textContent.hideChildrenTitle || "Réduire la branche"}
title={branchCollapsed ? textContent.showChildrenTitle || "Afficher la branche" : textContent.hideChildrenTitle || "Réduire la branche"}
>
<Icon name={branchCollapsed ? "chevron-down" : "chevron-up"} />
</button>
)}
{editing && (
<button className="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
)}
</div>
)}
</article>
{!node.collapsed && nestedChildren.length > 0 && (
<GoalTreeBranch
nodes={nodes}
parentId={node.id}
selectedNodeId={selectedNodeId}
editing={editing}
reorder={reorder}
textContent={textContent}
onSelect={onSelect}
onAdd={onAdd}
onAddContentItem={onAddContentItem}
onSetStepLeafQuantity={onSetStepLeafQuantity}
onSetStepChildrenCompletion={onSetStepChildrenCompletion}
onUpdateNodeItem={onUpdateNodeItem}
onEditNode={onEditNode}
onToggleBranch={onToggleBranch}
onDeleteNode={onDeleteNode}
/>
)}
</li>
);
})}
</ul>
);
}
function GoalTreeTitleInput({ value, onChange }) {
const [draft, setDraft] = useState(value || "");
return (
<input
value={draft}
onChange={(event) => {
const title = event.target.value;
setDraft(title);
onChange(title);
}}
/>
);
}
function GoalTreeFormulaInput({ value, onChange, hasParent, textContent, placeholder, title, className = "", "aria-label": ariaLabel }) {
const normalizedValue = String(value ?? "");
if (!hasParent) {
return (
<input
aria-label={ariaLabel}
className={className}
inputMode="decimal"
onChange={(event) => onChange(event.target.value.replaceAll(",", "."))}
placeholder={placeholder}
title={title}
value={normalizedValue}
/>
);
}
return (
<MixTagsInput
aria-label={ariaLabel}
className={className}
formulaSpacing
inputMode="decimal"
insertToken={{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
onChange={(nextValue) => onChange(nextValue.replaceAll(",", "."))}
placeholder={placeholder}
title={title}
tokens={[{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={normalizedValue}
/>
);
}
function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete }) {
return (
<div className="goal-tree-content-editor">
<div className="goal-tree-form-section-title">{textContent.contentSectionTitle || "Contenu"}</div>
{(node.items || []).map((item) => (
<div className={`goal-tree-content-editor-row is-${node.kind}`} key={item.id}>
<input
className="goal-tree-content-label-input"
value={item.label}
placeholder={textContent.itemLabelPlaceholder || "Élément"}
onChange={(event) => onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))}
aria-label={textContent.itemLabelLabel || "Libellé"}
/>
{node.kind === "checklist" && (
<GoalTreeFormulaInput
aria-label={textContent.itemTotalLabel || "Total"}
className="goal-tree-content-formula-input"
hasParent={Boolean(node.parentId)}
onChange={(value) => onUpdate(item.id, (current) => ({ ...current, formula: value.replaceAll(",", ".") }))}
value={item.formula || "1"}
placeholder={textContent.itemTotalPlaceholder || "base"}
textContent={textContent}
title={textContent.itemTotalHelp || "Saisissez un nombre ou une formule avec base pour référencer la cible du parent."}
/>
)}
<button className="danger" type="button" onClick={() => onDelete(item.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
))}
<button className="goal-tree-content-add-button" type="button" onClick={onAdd}>
<Icon name="add" />
<span>{textContent.addContentItemButton || "Ajouter une ligne"}</span>
</button>
</div>
);
}
function GoalTreeChecklistInlineControl({ item, textContent, onUpdate }) {
const target = Math.max(1, item.qtyTarget || 1);
const current = Math.min(Math.max(0, item.qtyCurrent || 0), target);
if (target <= 1) {
return (
<input
className="goal-tree-node-check"
type="checkbox"
checked={current >= 1}
onChange={(event) => onUpdate((entry) => ({ ...entry, qtyCurrent: event.target.checked ? 1 : 0 }))}
aria-label={textContent.itemCheckedLabel || "État"}
/>
);
}
return (
<span className="goal-tree-content-qty-stepper">
<button type="button" onClick={() => onUpdate((entry) => ({ ...entry, qtyCurrent: Math.max(0, (entry.qtyCurrent || 0) - 1) }))} aria-label={textContent.decrementItemTitle || "Retirer"} title={textContent.decrementItemTitle || "Retirer"}>
<span aria-hidden="true">-</span>
</button>
<span>{formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)}</span>
<button type="button" onClick={() => onUpdate((entry) => ({ ...entry, qtyCurrent: Math.min(target, (entry.qtyCurrent || 0) + 1) }))} aria-label={textContent.incrementItemTitle || "Ajouter"} title={textContent.incrementItemTitle || "Ajouter"}>
<Icon name="add" />
</button>
</span>
);
}
function GoalTreeStepLeafControl({ node, textContent, onChange }) {
const target = Math.max(1, node.computedQtyTarget || node.qtyTarget || 1);
const current = Math.min(Math.max(0, node.qtyCurrent || 0), target);
if (target <= 1) {
return (
<input
className="goal-tree-step-check"
type="checkbox"
checked={current >= 1}
onChange={(event) => onChange(event.target.checked ? 1 : 0)}
aria-label={textContent.itemCheckedLabel || "État"}
/>
);
}
return (
<span className="goal-tree-step-qty-stepper goal-tree-content-qty-stepper">
<button type="button" onClick={() => onChange(Math.max(0, current - 1))} aria-label={textContent.decrementItemTitle || "Retirer"} title={textContent.decrementItemTitle || "Retirer"}>
<span aria-hidden="true">-</span>
</button>
<span>{formatGoalTreeNumber(current)} / {formatGoalTreeNumber(target)}</span>
<button type="button" onClick={() => onChange(Math.min(target, current + 1))} aria-label={textContent.incrementItemTitle || "Ajouter"} title={textContent.incrementItemTitle || "Ajouter"}>
<Icon name="add" />
</button>
</span>
);
}
function GoalTreeNodeContentPreview({ node, editing, textContent, onAddContentItem, onUpdateItem }) {
const items = node.items || [];
return (
<span className="goal-tree-node-content-preview">
{items.map((item) => (
<span className="goal-tree-node-content-row" key={item.id}>
{node.kind === "checklist" && <GoalTreeChecklistInlineControl item={item} textContent={textContent} onUpdate={(updater) => onUpdateItem(item.id, updater)} />}
<Tooltip as="span" className="goal-tree-node-content-label" content={item.label} tooltipClassName="goal-tree-node-tooltip">
{item.label}
</Tooltip>
</span>
))}
{editing && (
<Tooltip
as="button"
className="goal-tree-node-content-add-button"
type="button"
onClick={onAddContentItem}
content={textContent.addContentItemTooltip || "Ajouter une entrée interne à cette carte."}
tooltipClassName="goal-tree-node-tooltip"
aria-label={`${textContent.addContentItemTitle || "Ajouter une entrée"} ${node.title}`}
>
<Icon name="add" />
</Tooltip>
)}
</span>
);
}
function GoalTreeIconPicker({ label, value, textContent, onChange }) {
return (
<div className="goal-tree-icon-field">
<span>{label}</span>
<CompactDropdown
className="goal-tree-icon-picker"
menuClassName="goal-tree-icon-picker-menu"
label={label}
renderTrigger={({ open, toggle }) => (
<button
className="goal-tree-icon-picker-button"
type="button"
onClick={toggle}
aria-label={label}
aria-expanded={open}
title={label}
>
<span className="goal-tree-icon-preview" aria-hidden="true">
<Icon name={value} />
</span>
<span>{textContent[`icon${value}`] || value}</span>
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<>
{GOAL_TREE_ICON_NAMES.map((icon) => (
<button
key={icon}
className={icon === value ? "active" : ""}
type="button"
onClick={() => {
onChange(icon);
close();
}}
aria-label={textContent[`icon${icon}`] || icon}
aria-pressed={icon === value}
title={textContent[`icon${icon}`] || icon}
>
<Icon name={icon} />
</button>
))}
</>
)}
</CompactDropdown>
</div>
);
}

View file

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

View file

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

View file

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