Refactor calculator module helpers
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
e512eef232
commit
61176ba9b7
5 changed files with 211 additions and 170 deletions
78
website/src/features/toolboxes/modules/CalculatorEntries.jsx
Normal file
78
website/src/features/toolboxes/modules/CalculatorEntries.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Rôle : rend l'arbre des résultats enregistrés du calculateur.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import {
|
||||
DISPLAY_NUMBER_FORMATTER,
|
||||
formatDisplayResult,
|
||||
formatResult,
|
||||
getChildren,
|
||||
getEntryResultLengthClass
|
||||
} from "./calculatorUtils.js";
|
||||
|
||||
export function FormattedCalculatorNumber({ value }) {
|
||||
if (!Number.isFinite(value)) return "-";
|
||||
let integerGroupIndex = 0;
|
||||
const normalized = Number.parseFloat(value.toFixed(6));
|
||||
return DISPLAY_NUMBER_FORMATTER.formatToParts(normalized).map((part, index) => {
|
||||
if (part.type === "integer") {
|
||||
const className = `calculator-number-group ${integerGroupIndex % 2 ? "is-muted" : "is-strong"}`;
|
||||
integerGroupIndex += 1;
|
||||
return <span className={className} key={`${part.type}-${index}`}>{part.value}</span>;
|
||||
}
|
||||
if (part.type === "group") return <span className="calculator-number-separator" key={`${part.type}-${index}`}>{part.value}</span>;
|
||||
return <span key={`${part.type}-${index}`}>{part.value}</span>;
|
||||
});
|
||||
}
|
||||
|
||||
export function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) {
|
||||
const children = getChildren(entries, parentId);
|
||||
if (!children.length) return null;
|
||||
|
||||
return (
|
||||
<ul className="tool-split-entry-list calculator-entry-list">
|
||||
{children.map((entry) => {
|
||||
const className = [
|
||||
entry.id === activeEntryId ? "active" : "",
|
||||
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
|
||||
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<li className={className} key={entry.id} {...reorder.getItemProps({ itemId: entry.id, parentId: getScopedParentId(parentId) })}>
|
||||
<div className="tool-split-entry calculator-entry has-drag-handle">
|
||||
<button
|
||||
className="calculator-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, entry.id)}
|
||||
aria-label={`${textContent.reorderTitle || "Déplacer"} ${entry.label || formatResult(entry.value)}`}
|
||||
title={textContent.reorderTitle || "Déplacer"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<button className="tool-split-entry-summary" type="button" onClick={() => onEdit(entry)} title={textContent.editTitle || "Modifier"}>
|
||||
<span>
|
||||
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
|
||||
<FormattedCalculatorNumber value={entry.value} />
|
||||
</strong>
|
||||
{entry.label && <em>{entry.label}</em>}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddChild(entry)}
|
||||
aria-label={`${textContent.addChildTitle || "Ajouter un calcul enfant"} ${entry.label || formatResult(entry.value)}`}
|
||||
title={textContent.addChildTitle || "Ajouter un calcul enfant"}
|
||||
>
|
||||
<Icon name="add" />
|
||||
</button>
|
||||
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeEntryId={activeEntryId} textContent={textContent} reorder={reorder} onEdit={onEdit} onAddChild={onAddChild} onDelete={onDelete} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,118 +3,20 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { CalculatorEntries, FormattedCalculatorNumber } from "./CalculatorEntries.jsx";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 });
|
||||
const BASE_TOKEN = "base";
|
||||
|
||||
function calculateExpression(expression, baseValue = null) {
|
||||
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
||||
if (!normalized) return null;
|
||||
const withBase = normalized.replace(/\bbase\b/gi, () => Number.isFinite(baseValue) ? `(${formatResult(baseValue)})` : "NaN");
|
||||
if (!/^[\d\s+\-*/().]+$/.test(withBase)) return null;
|
||||
|
||||
try {
|
||||
const value = Function(`"use strict"; return (${withBase})`)();
|
||||
return Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatResult(value) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
return Number.parseFloat(value.toFixed(6)).toString();
|
||||
}
|
||||
|
||||
function formatDisplayResult(value) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
return DISPLAY_NUMBER_FORMATTER.format(Number.parseFloat(value.toFixed(6)));
|
||||
}
|
||||
|
||||
function getResultLengthClass(value) {
|
||||
const length = formatResult(value).replace(/[^\d]/g, "").length;
|
||||
if (length >= 13) return "is-compact";
|
||||
if (length >= 9) return "is-long";
|
||||
return "";
|
||||
}
|
||||
|
||||
function getEntryResultLengthClass(value) {
|
||||
const length = formatResult(value).replace(/[^\d]/g, "").length;
|
||||
if (length >= 21) return "is-compact";
|
||||
if (length >= 18) return "is-long";
|
||||
return "";
|
||||
}
|
||||
|
||||
function getChildren(entries, parentId) {
|
||||
return entries.filter((entry) => (entry.parentId || "") === parentId);
|
||||
}
|
||||
|
||||
function getDescendantIds(entries, parentId) {
|
||||
const children = getChildren(entries, parentId);
|
||||
return children.flatMap((child) => [child.id, ...getDescendantIds(entries, child.id)]);
|
||||
}
|
||||
|
||||
function getEntriesInTreeOrder(entries, parentId = "") {
|
||||
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
|
||||
}
|
||||
|
||||
function getEntryFormula(entry) {
|
||||
return String(entry?.formula || "").trim();
|
||||
}
|
||||
|
||||
function getEditableFormula(entry) {
|
||||
return getEntryFormula(entry) || formatResult(entry.value);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function createStoredFormula(expression, parentValue) {
|
||||
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
||||
if (!Number.isFinite(parentValue)) return normalized;
|
||||
const parentText = formatResult(parentValue);
|
||||
if (/\bbase\b/i.test(normalized)) return normalized;
|
||||
const withBase = normalized.replace(new RegExp(`(^|[^\\d.])${escapeRegExp(parentText)}(?=$|[^\\d.])`, "g"), `$1${BASE_TOKEN}`);
|
||||
if (withBase !== normalized) return withBase;
|
||||
if (/^[+\-*/]/.test(normalized)) return `${BASE_TOKEN}${normalized}`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function evaluateCalculatorEntries(entries) {
|
||||
const byParent = new Map();
|
||||
entries.forEach((entry) => {
|
||||
const parentId = entry.parentId || "";
|
||||
byParent.set(parentId, [...(byParent.get(parentId) || []), entry]);
|
||||
});
|
||||
|
||||
function visit(parentId = "", parentValue = null) {
|
||||
return (byParent.get(parentId) || []).flatMap((entry) => {
|
||||
const formula = getEntryFormula(entry);
|
||||
const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value;
|
||||
const nextEntry = { ...entry, value: calculatedValue ?? entry.value };
|
||||
return [nextEntry, ...visit(entry.id, nextEntry.value)];
|
||||
});
|
||||
}
|
||||
|
||||
return visit();
|
||||
}
|
||||
|
||||
function FormattedCalculatorNumber({ value }) {
|
||||
if (!Number.isFinite(value)) return "-";
|
||||
let integerGroupIndex = 0;
|
||||
const normalized = Number.parseFloat(value.toFixed(6));
|
||||
return DISPLAY_NUMBER_FORMATTER.formatToParts(normalized).map((part, index) => {
|
||||
if (part.type === "integer") {
|
||||
const className = `calculator-number-group ${integerGroupIndex % 2 ? "is-muted" : "is-strong"}`;
|
||||
integerGroupIndex += 1;
|
||||
return <span className={className} key={`${part.type}-${index}`}>{part.value}</span>;
|
||||
}
|
||||
if (part.type === "group") return <span className="calculator-number-separator" key={`${part.type}-${index}`}>{part.value}</span>;
|
||||
return <span key={`${part.type}-${index}`}>{part.value}</span>;
|
||||
});
|
||||
}
|
||||
import {
|
||||
BASE_TOKEN,
|
||||
calculateExpression,
|
||||
createStoredFormula,
|
||||
evaluateCalculatorEntries,
|
||||
formatDisplayResult,
|
||||
formatResult,
|
||||
getDescendantIds,
|
||||
getEditableFormula,
|
||||
getEntriesInTreeOrder,
|
||||
getResultLengthClass
|
||||
} from "./calculatorUtils.js";
|
||||
|
||||
function focusExpressionInputAtEnd(element) {
|
||||
if (!element) return;
|
||||
|
|
@ -393,57 +295,3 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) {
|
||||
const children = getChildren(entries, parentId);
|
||||
if (!children.length) return null;
|
||||
|
||||
return (
|
||||
<ul className="tool-split-entry-list calculator-entry-list">
|
||||
{children.map((entry) => {
|
||||
const className = [
|
||||
entry.id === activeEntryId ? "active" : "",
|
||||
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
|
||||
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<li className={className} key={entry.id} {...reorder.getItemProps({ itemId: entry.id, parentId: getScopedParentId(parentId) })}>
|
||||
<div className="tool-split-entry calculator-entry has-drag-handle">
|
||||
<button
|
||||
className="calculator-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, entry.id)}
|
||||
aria-label={`${textContent.reorderTitle || "Déplacer"} ${entry.label || formatResult(entry.value)}`}
|
||||
title={textContent.reorderTitle || "Déplacer"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<button className="tool-split-entry-summary" type="button" onClick={() => onEdit(entry)} title={textContent.editTitle || "Modifier"}>
|
||||
<span>
|
||||
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
|
||||
<FormattedCalculatorNumber value={entry.value} />
|
||||
</strong>
|
||||
{entry.label && <em>{entry.label}</em>}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddChild(entry)}
|
||||
aria-label={`${textContent.addChildTitle || "Ajouter un calcul enfant"} ${entry.label || formatResult(entry.value)}`}
|
||||
title={textContent.addChildTitle || "Ajouter un calcul enfant"}
|
||||
>
|
||||
<Icon name="add" />
|
||||
</button>
|
||||
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeEntryId={activeEntryId} textContent={textContent} reorder={reorder} onEdit={onEdit} onAddChild={onAddChild} onDelete={onDelete} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
96
website/src/features/toolboxes/modules/calculatorUtils.js
Normal file
96
website/src/features/toolboxes/modules/calculatorUtils.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Rôle : regroupe les helpers purs du calculateur et de son arbre de résultats.
|
||||
export const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 });
|
||||
export const BASE_TOKEN = "base";
|
||||
|
||||
export function calculateExpression(expression, baseValue = null) {
|
||||
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
||||
if (!normalized) return null;
|
||||
const withBase = normalized.replace(/\bbase\b/gi, () => Number.isFinite(baseValue) ? `(${formatResult(baseValue)})` : "NaN");
|
||||
if (!/^[\d\s+\-*/().]+$/.test(withBase)) return null;
|
||||
|
||||
try {
|
||||
const value = Function(`"use strict"; return (${withBase})`)();
|
||||
return Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatResult(value) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
return Number.parseFloat(value.toFixed(6)).toString();
|
||||
}
|
||||
|
||||
export function formatDisplayResult(value) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
return DISPLAY_NUMBER_FORMATTER.format(Number.parseFloat(value.toFixed(6)));
|
||||
}
|
||||
|
||||
export function getResultLengthClass(value) {
|
||||
const length = formatResult(value).replace(/[^\d]/g, "").length;
|
||||
if (length >= 13) return "is-compact";
|
||||
if (length >= 9) return "is-long";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getEntryResultLengthClass(value) {
|
||||
const length = formatResult(value).replace(/[^\d]/g, "").length;
|
||||
if (length >= 21) return "is-compact";
|
||||
if (length >= 18) return "is-long";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getChildren(entries, parentId) {
|
||||
return entries.filter((entry) => (entry.parentId || "") === parentId);
|
||||
}
|
||||
|
||||
export function getDescendantIds(entries, parentId) {
|
||||
const children = getChildren(entries, parentId);
|
||||
return children.flatMap((child) => [child.id, ...getDescendantIds(entries, child.id)]);
|
||||
}
|
||||
|
||||
export function getEntriesInTreeOrder(entries, parentId = "") {
|
||||
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
|
||||
}
|
||||
|
||||
export function getEntryFormula(entry) {
|
||||
return String(entry?.formula || "").trim();
|
||||
}
|
||||
|
||||
export function getEditableFormula(entry) {
|
||||
return getEntryFormula(entry) || formatResult(entry.value);
|
||||
}
|
||||
|
||||
export function createStoredFormula(expression, parentValue) {
|
||||
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
||||
if (!Number.isFinite(parentValue)) return normalized;
|
||||
const parentText = formatResult(parentValue);
|
||||
if (/\bbase\b/i.test(normalized)) return normalized;
|
||||
const withBase = normalized.replace(new RegExp(`(^|[^\\d.])${escapeRegExp(parentText)}(?=$|[^\\d.])`, "g"), `$1${BASE_TOKEN}`);
|
||||
if (withBase !== normalized) return withBase;
|
||||
if (/^[+\-*/]/.test(normalized)) return `${BASE_TOKEN}${normalized}`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function evaluateCalculatorEntries(entries) {
|
||||
const byParent = new Map();
|
||||
entries.forEach((entry) => {
|
||||
const parentId = entry.parentId || "";
|
||||
byParent.set(parentId, [...(byParent.get(parentId) || []), entry]);
|
||||
});
|
||||
|
||||
function visit(parentId = "", parentValue = null) {
|
||||
return (byParent.get(parentId) || []).flatMap((entry) => {
|
||||
const formula = getEntryFormula(entry);
|
||||
const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value;
|
||||
const nextEntry = { ...entry, value: calculatedValue ?? entry.value };
|
||||
return [nextEntry, ...visit(entry.id, nextEntry.value)];
|
||||
});
|
||||
}
|
||||
|
||||
return visit();
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue