Split remaining toolbox modules by responsibility
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
This commit is contained in:
parent
43a9e791c5
commit
a0d7e80a1a
25 changed files with 2199 additions and 1992 deletions
|
|
@ -11,6 +11,10 @@ import { getCalendarOccurrences, getCurrentCalendarOccurrence, getMonthEnd, getM
|
|||
import { exportModuleText, importModuleText } from "../website/src/features/toolboxes/modules/textImport.js";
|
||||
import { compactCalendarDataForStorage, normalizeCalendarData } from "../website/src/features/toolboxes/storage/modules/calendar.js";
|
||||
|
||||
function countLines(source) {
|
||||
return source.trimEnd().split("\n").length;
|
||||
}
|
||||
|
||||
test("toolbox storage, cards and pages are wired", async () => {
|
||||
const source = await readFile("website/src/main.jsx", "utf8");
|
||||
const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.jsx", "utf8");
|
||||
|
|
@ -142,6 +146,10 @@ test("toolbox storage, cards and pages are wired", async () => {
|
|||
test("toolbox module registry and modules expose expected behavior", async () => {
|
||||
const source = await readFile("website/src/main.jsx", "utf8");
|
||||
const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8");
|
||||
const moduleComponents = await readFile("website/src/features/toolboxes/modules/toolboxModuleComponents.jsx", "utf8");
|
||||
const addToolControls = await readFile("website/src/features/toolboxes/modules/AddToolControls.jsx", "utf8");
|
||||
const toolboxModules = await readFile("website/src/features/toolboxes/modules/ToolboxModules.jsx", "utf8");
|
||||
const moduleRegistryBundle = `${moduleRegistry}\n${moduleComponents}\n${addToolControls}\n${toolboxModules}`;
|
||||
const moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8");
|
||||
const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8");
|
||||
const notepadToolbar = await readFile("website/src/features/toolboxes/modules/NotepadToolbar.jsx", "utf8");
|
||||
|
|
@ -169,30 +177,44 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
|
||||
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
|
||||
const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8");
|
||||
const timerCountdown = await readFile("website/src/features/toolboxes/modules/TimerCountdown.jsx", "utf8");
|
||||
const timerSharedControls = await readFile("website/src/features/toolboxes/modules/TimerSharedControls.jsx", "utf8");
|
||||
const timerStopwatch = await readFile("website/src/features/toolboxes/modules/TimerStopwatch.jsx", "utf8");
|
||||
const timerBundle = `${timerModule}\n${timerParts}\n${timerCountdown}\n${timerSharedControls}\n${timerStopwatch}`;
|
||||
const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
|
||||
const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "utf8");
|
||||
const calendarController = await readFile("website/src/features/toolboxes/modules/useCalendarController.js", "utf8");
|
||||
const calendarDraftControls = await readFile("website/src/features/toolboxes/modules/useCalendarDraftControls.js", "utf8");
|
||||
const calendarEventActions = await readFile("website/src/features/toolboxes/modules/calendarEventActions.js", "utf8");
|
||||
const calendarLifecycle = await readFile("website/src/features/toolboxes/modules/useCalendarLifecycle.js", "utf8");
|
||||
const calendarModeChoice = await readFile("website/src/features/toolboxes/modules/CalendarModeChoice.jsx", "utf8");
|
||||
const calendarContent = await readFile("website/src/features/toolboxes/modules/CalendarContent.jsx", "utf8");
|
||||
const calendarDraft = await readFile("website/src/features/toolboxes/modules/calendarDraft.js", "utf8");
|
||||
const calendarEventForm = await readFile("website/src/features/toolboxes/modules/CalendarEventForm.jsx", "utf8");
|
||||
const calendarEventControls = await readFile("website/src/features/toolboxes/modules/CalendarEventControls.jsx", "utf8");
|
||||
const calendarMonthDatePicker = await readFile("website/src/features/toolboxes/modules/CalendarMonthDatePicker.jsx", "utf8");
|
||||
const calendarViews = await readFile("website/src/features/toolboxes/modules/CalendarViews.jsx", "utf8");
|
||||
const calendarBundle = `${calendarModule}\n${calendarContent}\n${calendarDraft}\n${calendarEventForm}\n${calendarEventControls}\n${calendarMonthDatePicker}\n${calendarViews}`;
|
||||
const calendarBundle = `${calendarModule}\n${calendarController}\n${calendarDraftControls}\n${calendarEventActions}\n${calendarLifecycle}\n${calendarModeChoice}\n${calendarContent}\n${calendarDraft}\n${calendarEventForm}\n${calendarEventControls}\n${calendarMonthDatePicker}\n${calendarViews}`;
|
||||
const calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "utf8");
|
||||
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
|
||||
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
|
||||
const taskPlannerBranch = await readFile("website/src/features/toolboxes/modules/TaskPlannerBranch.jsx", "utf8");
|
||||
const taskPlannerItem = await readFile("website/src/features/toolboxes/modules/TaskPlannerItem.jsx", "utf8");
|
||||
const taskPlannerItemPanels = await readFile("website/src/features/toolboxes/modules/TaskPlannerItemPanels.jsx", "utf8");
|
||||
const taskPlannerReorder = await readFile("website/src/features/toolboxes/modules/useTaskPlannerReorder.js", "utf8");
|
||||
const taskPlannerTopbar = await readFile("website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx", "utf8");
|
||||
const taskPlannerSettings = await readFile("website/src/features/toolboxes/modules/TaskPlannerSettings.jsx", "utf8");
|
||||
const taskPlannerBundle = `${taskPlannerItems}\n${taskPlannerItemPanels}\n${taskPlannerReorder}\n${taskPlannerTopbar}\n${taskPlannerSettings}`;
|
||||
const taskPlannerBundle = `${taskPlannerItems}\n${taskPlannerBranch}\n${taskPlannerItem}\n${taskPlannerItemPanels}\n${taskPlannerReorder}\n${taskPlannerTopbar}\n${taskPlannerSettings}`;
|
||||
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 goalTreeEditor = await readFile("website/src/features/toolboxes/modules/GoalTreeEditor.jsx", "utf8");
|
||||
const goalTreeParts = await readFile("website/src/features/toolboxes/modules/GoalTreeParts.jsx", "utf8");
|
||||
const goalTreeBranch = await readFile("website/src/features/toolboxes/modules/GoalTreeBranch.jsx", "utf8");
|
||||
const goalTreeEditorFields = await readFile("website/src/features/toolboxes/modules/GoalTreeEditorFields.jsx", "utf8");
|
||||
const goalTreeNodeContent = await readFile("website/src/features/toolboxes/modules/GoalTreeNodeContent.jsx", "utf8");
|
||||
const goalTreeController = await readFile("website/src/features/toolboxes/modules/useGoalTreeController.js", "utf8");
|
||||
const goalTreeView = await readFile("website/src/features/toolboxes/modules/GoalTreeView.jsx", "utf8");
|
||||
const goalTreeBundle = `${goalTreeModule}\n${goalTreeEditor}\n${goalTreeParts}\n${goalTreeController}\n${goalTreeView}`;
|
||||
const goalTreeBundle = `${goalTreeModule}\n${goalTreeEditor}\n${goalTreeParts}\n${goalTreeBranch}\n${goalTreeEditorFields}\n${goalTreeNodeContent}\n${goalTreeController}\n${goalTreeView}`;
|
||||
const goalTreeDisplay = await readFile("website/src/features/toolboxes/modules/goalTreeDisplay.js", "utf8");
|
||||
const goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.js", "utf8");
|
||||
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8");
|
||||
|
|
@ -222,51 +244,81 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8");
|
||||
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
|
||||
|
||||
[
|
||||
["CalendarModule.jsx", calendarModule],
|
||||
["modules/index.jsx", moduleRegistry],
|
||||
["GoalTreeParts.jsx", goalTreeParts],
|
||||
["TimerParts.jsx", timerParts],
|
||||
["TaskPlannerItems.jsx", taskPlannerItems]
|
||||
].forEach(([fileName, fileSource]) => {
|
||||
assert.ok(countLines(fileSource) <= 220, `${fileName} must remain a short orchestrator/barrel`);
|
||||
});
|
||||
|
||||
[
|
||||
["AddToolControls.jsx", addToolControls],
|
||||
["ToolboxModules.jsx", toolboxModules],
|
||||
["useCalendarController.js", calendarController],
|
||||
["useCalendarDraftControls.js", calendarDraftControls],
|
||||
["calendarEventActions.js", calendarEventActions],
|
||||
["useCalendarLifecycle.js", calendarLifecycle],
|
||||
["CalendarModeChoice.jsx", calendarModeChoice],
|
||||
["GoalTreeBranch.jsx", goalTreeBranch],
|
||||
["GoalTreeEditorFields.jsx", goalTreeEditorFields],
|
||||
["GoalTreeNodeContent.jsx", goalTreeNodeContent],
|
||||
["TimerCountdown.jsx", timerCountdown],
|
||||
["TimerSharedControls.jsx", timerSharedControls],
|
||||
["TimerStopwatch.jsx", timerStopwatch],
|
||||
["TaskPlannerBranch.jsx", taskPlannerBranch],
|
||||
["TaskPlannerItem.jsx", taskPlannerItem]
|
||||
].forEach(([fileName, fileSource]) => {
|
||||
assert.ok(countLines(fileSource) <= 300, `${fileName} must stay focused; split by responsibility before growing further`);
|
||||
});
|
||||
|
||||
assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULES/);
|
||||
assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULE_DEFINITIONS/);
|
||||
assert.match(moduleMetadataRegistry, /export function getDefaultModuleTitle/);
|
||||
assert.match(moduleRegistry, /export function AddToolControls/);
|
||||
assert.match(moduleRegistry, /export function ToolboxModules/);
|
||||
assert.match(moduleRegistry, /MODULE_COMPONENTS/);
|
||||
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/CombosModule\.jsx"\)/);
|
||||
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/CalendarModule\.jsx"\)/);
|
||||
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/GoalTreeModule\.jsx"\)/);
|
||||
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/EquipmentPlannerModule\.jsx"\)/);
|
||||
assert.match(moduleRegistry, /Suspense fallback=\{<ModuleLoadingFallback label=\{label\} \/>\}/);
|
||||
assert.match(moduleRegistry, /LAZY_MODULE_TYPES/);
|
||||
assert.match(moduleRegistry, /notepad:/);
|
||||
assert.match(moduleRegistry, /checklist:/);
|
||||
assert.match(moduleRegistry, /images:/);
|
||||
assert.match(moduleRegistry, /links:/);
|
||||
assert.match(moduleRegistry, /counters:/);
|
||||
assert.match(moduleRegistry, /combos:/);
|
||||
assert.match(moduleRegistry, /CombosModule/);
|
||||
assert.match(moduleRegistry, /calculator:/);
|
||||
assert.match(moduleRegistry, /table:/);
|
||||
assert.match(moduleRegistry, /table: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.table, Component: TableModule \}/);
|
||||
assert.match(moduleRegistry, /timer:/);
|
||||
assert.match(moduleRegistry, /calendar:/);
|
||||
assert.match(moduleRegistry, /taskPlanner:/);
|
||||
assert.match(moduleRegistry, /goalTree:/);
|
||||
assert.match(moduleRegistry, /equipmentPlanner:/);
|
||||
assert.match(moduleRegistry, /export \{ AddToolControls \}/);
|
||||
assert.match(moduleRegistry, /export \{ ToolboxModules \}/);
|
||||
assert.match(moduleComponents, /MODULE_COMPONENTS/);
|
||||
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/CombosModule\.jsx"\)/);
|
||||
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/CalendarModule\.jsx"\)/);
|
||||
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/GoalTreeModule\.jsx"\)/);
|
||||
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/EquipmentPlannerModule\.jsx"\)/);
|
||||
assert.match(toolboxModules, /Suspense fallback=\{<ModuleLoadingFallback label=\{label\} \/>\}/);
|
||||
assert.match(moduleComponents, /LAZY_MODULE_TYPES/);
|
||||
assert.match(moduleComponents, /notepad:/);
|
||||
assert.match(moduleComponents, /checklist:/);
|
||||
assert.match(moduleComponents, /images:/);
|
||||
assert.match(moduleComponents, /links:/);
|
||||
assert.match(moduleComponents, /counters:/);
|
||||
assert.match(moduleComponents, /combos:/);
|
||||
assert.match(moduleComponents, /CombosModule/);
|
||||
assert.match(moduleComponents, /calculator:/);
|
||||
assert.match(moduleComponents, /table:/);
|
||||
assert.match(moduleComponents, /table: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.table, Component: TableModule \}/);
|
||||
assert.match(moduleComponents, /timer:/);
|
||||
assert.match(moduleComponents, /calendar:/);
|
||||
assert.match(moduleComponents, /taskPlanner:/);
|
||||
assert.match(moduleComponents, /goalTree:/);
|
||||
assert.match(moduleComponents, /equipmentPlanner:/);
|
||||
assert.match(moduleMetadataRegistry, /Planificateur d'équipements/);
|
||||
assert.match(moduleRegistry, /imageAnnotation:/);
|
||||
assert.match(moduleComponents, /imageAnnotation:/);
|
||||
assert.match(moduleMetadataRegistry, /Annotation d'images/);
|
||||
assert.match(moduleRegistry, /notepad: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.notepad, Component: NotepadModule \}/);
|
||||
assert.match(moduleRegistry, /module-edit-button/);
|
||||
assert.match(moduleRegistry, /itemReorder\.startDrag/);
|
||||
assert.match(moduleRegistry, /onPointerDown/);
|
||||
assert.match(moduleRegistry, /useGroupedReorder/);
|
||||
assert.match(moduleRegistry, /useInlineEdit/);
|
||||
assert.match(moduleRegistry, /getContentEditableProps/);
|
||||
assert.match(moduleRegistry, /tool-add-card/);
|
||||
assert.match(moduleRegistry, /tool-quick-add-button/);
|
||||
assert.match(moduleRegistry, /tool-add-quick-toggle/);
|
||||
assert.match(moduleComponents, /notepad: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.notepad, Component: NotepadModule \}/);
|
||||
assert.match(toolboxModules, /module-edit-button/);
|
||||
assert.match(toolboxModules, /itemReorder\.startDrag/);
|
||||
assert.match(moduleRegistryBundle, /onPointerDown/);
|
||||
assert.match(toolboxModules, /useGroupedReorder/);
|
||||
assert.match(toolboxModules, /useInlineEdit/);
|
||||
assert.match(toolboxModules, /getContentEditableProps/);
|
||||
assert.match(addToolControls, /tool-add-card/);
|
||||
assert.match(addToolControls, /tool-quick-add-button/);
|
||||
assert.match(addToolControls, /tool-add-quick-toggle/);
|
||||
assert.match(moduleMetadataRegistry, /scrollable: true/);
|
||||
assert.match(moduleRegistry, /module-content/);
|
||||
assert.match(moduleRegistry, /module-scroll-button/);
|
||||
assert.match(moduleRegistry, /Icon name="dropdown"/);
|
||||
assert.match(moduleRegistry, /CompactDropdown/);
|
||||
assert.match(toolboxModules, /module-content/);
|
||||
assert.match(toolboxModules, /module-scroll-button/);
|
||||
assert.match(addToolControls, /Icon name="dropdown"/);
|
||||
assert.match(addToolControls, /CompactDropdown/);
|
||||
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
|
||||
assert.match(notepadModule, /export function NotepadModule/);
|
||||
assert.match(notepadModule, /TextExchangeActions/);
|
||||
|
|
@ -364,7 +416,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
assert.match(timerModule, /Tabs/);
|
||||
assert.match(timerModule, /stopwatch/);
|
||||
assert.match(timerModule, /countdown/);
|
||||
assert.match(timerParts, /useInlineEdit/);
|
||||
assert.match(timerBundle, /useInlineEdit/);
|
||||
assert.match(timerModule, /useDraftForm/);
|
||||
assert.match(timerModule, /normalizeTimerData/);
|
||||
assert.match(timerModule, /TextExchangeActions/);
|
||||
|
|
@ -378,16 +430,16 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
assert.match(calendarBundle, /MonthDateDropdown/);
|
||||
assert.match(calendarBundle, /MonthEventRepeatSwitch/);
|
||||
assert.match(calendarBundle, /monthlyRepeatBy/);
|
||||
assert.match(calendarModule, /displayedMonth/);
|
||||
assert.match(calendarModule, /deleteOccurrence/);
|
||||
assert.match(calendarController, /displayedMonth/);
|
||||
assert.match(calendarController, /deleteOccurrence/);
|
||||
assert.match(calendarBundle, /onPointerDown/);
|
||||
assert.match(calendarModule, /copyEvent/);
|
||||
assert.match(calendarController, /copyEvent/);
|
||||
assert.match(calendarBundle, /calendar-repeat-switch/);
|
||||
assert.match(calendarBundle, /layout-switch calendar-month-repeat-switch/);
|
||||
assert.match(calendarBundle, /WEEK_TIME_INPUT_STEP_MINUTES \* 60/);
|
||||
assert.match(calendarBundle, /durationMinutes < 120/);
|
||||
assert.match(calendarBundle, /is-compact/);
|
||||
assert.match(calendarModule, /handleKeyDown/);
|
||||
assert.match(calendarController, /handleKeyDown/);
|
||||
assert.match(calendarContent, /TextExchangeActions/);
|
||||
assert.match(calendarContent, /type="calendar"/);
|
||||
assert.match(calendarBundle, /repeatLabel/);
|
||||
|
|
@ -408,8 +460,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
assert.match(taskPlannerModule, /useDraftForm/);
|
||||
assert.match(taskPlannerBundle, /TextExchangeActions/);
|
||||
assert.match(taskPlannerBundle, /reorderFeatures/);
|
||||
assert.match(taskPlannerItems, /export function TaskPlannerBranch/);
|
||||
assert.match(taskPlannerItems, /function TaskPlannerItem/);
|
||||
assert.match(taskPlannerBranch, /export function TaskPlannerBranch/);
|
||||
assert.match(taskPlannerItem, /export function TaskPlannerItem/);
|
||||
assert.match(taskPlannerBundle, /task-planner-warning/);
|
||||
assert.match(taskPlannerBundle, /useInlineEdit/);
|
||||
assert.match(taskPlannerBundle, /commitOnEnter: false/);
|
||||
|
|
@ -428,14 +480,14 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
assert.match(goalTreeEditor, /Tabs/);
|
||||
assert.match(goalTreeEditor, /GoalTreeFormulaInput/);
|
||||
assert.match(goalTreeEditor, /GoalTreePrerequisitePicker/);
|
||||
assert.match(goalTreeParts, /Tooltip/);
|
||||
assert.match(goalTreeParts, /export function GoalTreeIconPicker/);
|
||||
assert.match(goalTreeParts, /export function GoalTreeContentEditor/);
|
||||
assert.match(goalTreeParts, /function GoalTreeNodeContentPreview/);
|
||||
assert.match(goalTreeBranch, /Tooltip/);
|
||||
assert.match(goalTreeEditorFields, /export function GoalTreeIconPicker/);
|
||||
assert.match(goalTreeEditorFields, /export function GoalTreeContentEditor/);
|
||||
assert.match(goalTreeNodeContent, /export function GoalTreeNodeContentPreview/);
|
||||
assert.doesNotMatch(goalTreeBundle, /\.slice\(0, 3\)/);
|
||||
assert.match(goalTreeParts, /GoalTreeChecklistInlineControl/);
|
||||
assert.match(goalTreeParts, /GoalTreeStepLeafControl/);
|
||||
assert.match(goalTreeParts, /export function GoalTreePrerequisitePicker/);
|
||||
assert.match(goalTreeNodeContent, /GoalTreeChecklistInlineControl/);
|
||||
assert.match(goalTreeNodeContent, /GoalTreeStepLeafControl/);
|
||||
assert.match(goalTreeEditorFields, /export function GoalTreePrerequisitePicker/);
|
||||
assert.match(goalTreeBundle, /function toggleBranch/);
|
||||
assert.match(goalTreeBundle, /function editNode/);
|
||||
assert.match(goalTreeBundle, /function addNodeContentItem/);
|
||||
|
|
@ -453,7 +505,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
assert.match(goalTreeDisplay, /progress\.current/);
|
||||
assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
|
||||
assert.doesNotMatch(goalTreeUtils, /"calculator"/);
|
||||
assert.match(goalTreeParts, /getItemProps\(\{ itemId: node\.id, parentId \}\)/);
|
||||
assert.match(goalTreeBranch, /getItemProps\(\{ itemId: node\.id, parentId \}\)/);
|
||||
assert.match(goalTreeUtils, /export function calculateGoalTreeFormula/);
|
||||
assert.match(goalTreeUtils, /export function evaluateGoalTreeNodes/);
|
||||
assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/);
|
||||
|
|
@ -540,10 +592,10 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
assert.match(timerModule, /useGroupedReorder/);
|
||||
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
|
||||
assert.match(timerModule, /parentId=\{moduleId\}/);
|
||||
assert.match(timerParts, /timer-drag-handle/);
|
||||
assert.match(timerParts, /export function StopwatchControls/);
|
||||
assert.match(timerParts, /export function CountdownControls/);
|
||||
assert.match(timerParts, /export function CountdownList/);
|
||||
assert.match(timerCountdown, /timer-drag-handle/);
|
||||
assert.match(timerStopwatch, /export function StopwatchControls/);
|
||||
assert.match(timerCountdown, /export function CountdownControls/);
|
||||
assert.match(timerCountdown, /export function CountdownList/);
|
||||
assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/);
|
||||
assert.match(textImport, /export \{ parseColonImportLines \}/);
|
||||
assert.match(textImport, /export function exportModuleText/);
|
||||
|
|
|
|||
105
website/src/features/toolboxes/modules/AddToolControls.jsx
Normal file
105
website/src/features/toolboxes/modules/AddToolControls.jsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Rôle : rend les contrôles modal et quick-add d'ajout d'outils toolbox.
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
|
||||
import { TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
|
||||
|
||||
export function AddToolControls({ onAdd }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const modules = Object.entries(TOOLBOX_MODULES);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
return lockBodyScroll();
|
||||
}, [open]);
|
||||
|
||||
function addTool(type) {
|
||||
onAdd(type);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="tool-add-controls"
|
||||
aria-label="Ajouter un outil"
|
||||
>
|
||||
<button
|
||||
className="primary tool-add-modal-button"
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
data-toolbox-tutorial-target="addTool"
|
||||
>
|
||||
Ajouter un outil
|
||||
</button>
|
||||
<CompactDropdown
|
||||
className="tool-add-quick-dropdown"
|
||||
menuClassName="tool-quick-add"
|
||||
label="Ajout rapide"
|
||||
openOnHover
|
||||
closeOnMouseLeave
|
||||
renderTrigger={({ open: quickOpen, toggle }) => (
|
||||
<button
|
||||
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label="Afficher l'ajout rapide"
|
||||
aria-expanded={quickOpen}
|
||||
title="Ajout rapide"
|
||||
>
|
||||
<Icon name="dropdown" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{modules.map(([type, module]) => (
|
||||
<button
|
||||
key={type}
|
||||
className="tool-quick-add-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAdd(type);
|
||||
close();
|
||||
}}
|
||||
aria-label={`Ajouter ${module.label}`}
|
||||
title={module.label}
|
||||
>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className={`module-icon-svg module-icon-${module.icon}`} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CompactDropdown>
|
||||
</div>
|
||||
{open && createPortal(
|
||||
<div className="tool-add-modal-root" role="dialog" aria-modal="true" aria-labelledby="tool-add-modal-title">
|
||||
<div className="confirm-backdrop" onClick={() => setOpen(false)} />
|
||||
<section className="confirm-modal tool-add-modal">
|
||||
<header>
|
||||
<h2 id="tool-add-modal-title">Ajouter un outil</h2>
|
||||
<button className="drawer-close-button" type="button" onClick={() => setOpen(false)} aria-label="Fermer" title="Fermer">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="tool-add-grid">
|
||||
{modules.map(([type, module]) => (
|
||||
<button key={type} className="tool-add-card" type="button" onClick={() => addTool(type)}>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className={`module-icon-svg module-icon-${module.icon}`} />
|
||||
</span>
|
||||
<span>{module.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Rôle : rend l'écran de choix initial du mode calendrier.
|
||||
export function CalendarModeChoice({ textContent, onInitializeWeek, onInitializeMonth }) {
|
||||
return (
|
||||
<div className="calendar-module">
|
||||
<section className="calendar-mode-choice" aria-label={textContent.init?.title || "Créer un calendrier"}>
|
||||
<div className="calendar-mode-grid">
|
||||
<button type="button" className="calendar-mode-card" onClick={onInitializeWeek}>
|
||||
<strong>{textContent.init?.weekly || "Hebdomadaire"}</strong>
|
||||
<span>{textContent.init?.weeklyDescription || "Grille jours par créneaux de 30 minutes."}</span>
|
||||
</button>
|
||||
<button type="button" className="calendar-mode-card" onClick={onInitializeMonth}>
|
||||
<strong>{textContent.init?.monthly || "Mensuel"}</strong>
|
||||
<span>{textContent.init?.monthlyDescription || "Grille du mois courant."}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,433 +1,22 @@
|
|||
// Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
// Rôle : orchestre l'outil calendrier et délègue l'état métier à son contrôleur.
|
||||
import { CalendarContent } from "./CalendarContent.jsx";
|
||||
import {
|
||||
CALENDAR_ALERT_MODES,
|
||||
CALENDAR_COLORS,
|
||||
WEEK_SLOT_MINUTES,
|
||||
addDays,
|
||||
formatLocalDateKey,
|
||||
getCalendarOccurrences,
|
||||
getLocalDayIndex,
|
||||
getMonthEnd,
|
||||
getMonthGridDays,
|
||||
getMonthStart,
|
||||
getWeekDays,
|
||||
getWeekStart,
|
||||
minutesToTimeValue,
|
||||
parseLocalDateKey,
|
||||
timeValueToMinutes
|
||||
} from "./calendarUtils.js";
|
||||
import { WEEK_SLOT_COUNT, getWeekGridLayout } from "./calendarLayout.js";
|
||||
import {
|
||||
createDefaultDraft,
|
||||
getCalendarRepeatMode,
|
||||
getDateSpanDays,
|
||||
getDraftSelection,
|
||||
normalizeDraftAlertOffsets,
|
||||
normalizeDraftDateKey,
|
||||
normalizeDraftMonthlyRepeatBy
|
||||
} from "./calendarDraft.js";
|
||||
import { CalendarModeChoice } from "./CalendarModeChoice.jsx";
|
||||
import { useCalendarController } from "./useCalendarController.js";
|
||||
|
||||
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
|
||||
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
|
||||
const textContent = context.moduleText?.calendar || {};
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [displayedMonth, setDisplayedMonth] = useState(() => getMonthStart(new Date()));
|
||||
const weekStart = useMemo(() => getWeekStart(new Date(), data.weekStartsOn), [data.weekStartsOn]);
|
||||
const monthStart = useMemo(() => getMonthStart(displayedMonth), [displayedMonth]);
|
||||
const monthEnd = useMemo(() => getMonthEnd(monthStart), [monthStart]);
|
||||
const monthGridDays = useMemo(() => getMonthGridDays(monthStart), [monthStart]);
|
||||
const [selection, setSelection] = useState(null);
|
||||
const [monthSelection, setMonthSelection] = useState(null);
|
||||
const [draft, setDraft] = useState(() => createDefaultDraft());
|
||||
const [activeEventId, setActiveEventId] = useState("");
|
||||
const [activeOccurrenceDate, setActiveOccurrenceDate] = useState("");
|
||||
const [copiedEvent, setCopiedEvent] = useState(null);
|
||||
const [hideEmptyHours, setHideEmptyHours] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const previousEditingRef = useRef(editing);
|
||||
const days = useMemo(() => getWeekDays(weekStart), [weekStart]);
|
||||
const weekEnd = useMemo(() => addDays(weekStart, 7), [weekStart]);
|
||||
const rangeStart = data.viewMode === "month" ? monthStart : weekStart;
|
||||
const rangeEnd = data.viewMode === "month" ? monthEnd : weekEnd;
|
||||
const occurrences = useMemo(() => getCalendarOccurrences(data.events, rangeStart, rangeEnd), [data.events, rangeEnd, rangeStart]);
|
||||
const draftSelection = data.viewMode === "week" ? getDraftSelection(draft) : null;
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const nowDayIndex = getLocalDayIndex(now);
|
||||
const formOpen = editing || Boolean(activeEventId);
|
||||
const weekGridLayout = useMemo(() => {
|
||||
return getWeekGridLayout(occurrences, data.viewMode === "week" && hideEmptyHours && !formOpen);
|
||||
}, [data.viewMode, formOpen, hideEmptyHours, occurrences]);
|
||||
|
||||
const save = useCallback((nextData) => {
|
||||
context.setModuleData(toolboxId, moduleId, nextData, "calendar");
|
||||
}, [context, moduleId, toolboxId]);
|
||||
|
||||
function initializeWeek() {
|
||||
save({ ...data, initialized: true, viewMode: "week", weekStartsOn: 1, events: [] });
|
||||
}
|
||||
|
||||
function initializeMonth() {
|
||||
save({ ...data, initialized: true, viewMode: "month", weekStartsOn: 1, events: [] });
|
||||
}
|
||||
|
||||
function updateDraft(values) {
|
||||
setDraft((current) => ({ ...current, ...values }));
|
||||
setError("");
|
||||
}
|
||||
|
||||
function updateAlertMode(alertMode) {
|
||||
setDraft((current) => {
|
||||
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
|
||||
return {
|
||||
...current,
|
||||
alertMode,
|
||||
alertOffsetMinutes: alertMode === "off" ? [] : currentOffsets.length ? currentOffsets : [10]
|
||||
};
|
||||
});
|
||||
setError("");
|
||||
}
|
||||
|
||||
function toggleAlertOffset(offset) {
|
||||
setDraft((current) => {
|
||||
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
|
||||
const nextOffsets = currentOffsets.includes(offset)
|
||||
? currentOffsets.filter((item) => item !== offset)
|
||||
: [...currentOffsets, offset].sort((a, b) => b - a);
|
||||
return {
|
||||
...current,
|
||||
alertMode: nextOffsets.length ? current.alertMode === "off" ? "visible" : current.alertMode : "off",
|
||||
alertOffsetMinutes: nextOffsets
|
||||
};
|
||||
});
|
||||
setError("");
|
||||
}
|
||||
|
||||
const resetDraft = useCallback(() => {
|
||||
setDraft(createDefaultDraft());
|
||||
setSelection(null);
|
||||
setMonthSelection(null);
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
function closeForm() {
|
||||
resetDraft();
|
||||
setEditing?.(false);
|
||||
}
|
||||
|
||||
function applySelection(dayIndex, startSlot, endSlot = startSlot) {
|
||||
const orderedStart = Math.min(startSlot, endSlot);
|
||||
const orderedEnd = Math.max(startSlot, endSlot);
|
||||
updateDraft({
|
||||
id: "",
|
||||
dayIndex,
|
||||
startTime: minutesToTimeValue(orderedStart * WEEK_SLOT_MINUTES),
|
||||
endTime: minutesToTimeValue(Math.min(23 * 60 + 59, (Math.min(WEEK_SLOT_COUNT, orderedEnd + 1)) * WEEK_SLOT_MINUTES))
|
||||
});
|
||||
}
|
||||
|
||||
function applyMonthSelection(startDate, endDate = startDate) {
|
||||
const orderedStart = startDate <= endDate ? startDate : endDate;
|
||||
const orderedEnd = startDate <= endDate ? endDate : startDate;
|
||||
const start = parseLocalDateKey(orderedStart);
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
updateDraft({
|
||||
id: "",
|
||||
startDate: orderedStart,
|
||||
endDate: orderedEnd,
|
||||
dayIndex: start ? getLocalDayIndex(start) : 0
|
||||
});
|
||||
}
|
||||
|
||||
function pasteEventAtTarget(target) {
|
||||
if (!copiedEvent) return;
|
||||
const startMinutes = data.viewMode === "month" ? 0 : target.slotIndex >= 0 ? target.slotIndex * WEEK_SLOT_MINUTES : timeValueToMinutes(draft.startTime);
|
||||
const durationMinutes = copiedEvent.endMinutes && copiedEvent.endMinutes > copiedEvent.startMinutes ? copiedEvent.endMinutes - copiedEvent.startMinutes : 0;
|
||||
const nextEvent = {
|
||||
...copiedEvent,
|
||||
id: context.uid("calendar"),
|
||||
startMinutes,
|
||||
repeatable: copiedEvent.repeatable !== false,
|
||||
repeat: copiedEvent.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode)
|
||||
};
|
||||
if (data.viewMode === "month") {
|
||||
const copiedDaySpan = getDateSpanDays(copiedEvent.startDate, copiedEvent.endDate || copiedEvent.startDate);
|
||||
nextEvent.startDate = target.startDate;
|
||||
nextEvent.endDate = formatLocalDateKey(addDays(parseLocalDateKey(target.startDate), copiedDaySpan));
|
||||
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(target.startDate));
|
||||
nextEvent.monthlyRepeatBy = target.monthlyRepeatBy || copiedEvent.monthlyRepeatBy || "monthDay";
|
||||
nextEvent.repeat = copiedEvent.repeat || "monthly";
|
||||
nextEvent.excludedOccurrences = [];
|
||||
delete nextEvent.monthDay;
|
||||
} else {
|
||||
nextEvent.dayIndex = target.dayIndex;
|
||||
delete nextEvent.monthDay;
|
||||
delete nextEvent.monthlyRepeatBy;
|
||||
}
|
||||
delete nextEvent.endMinutes;
|
||||
if (data.viewMode === "week" && durationMinutes > 0) nextEvent.endMinutes = Math.min(23 * 60 + 59, startMinutes + durationMinutes);
|
||||
save({ ...data, initialized: true, events: [...data.events, nextEvent] });
|
||||
context.notify?.(textContent.pastedTitle || "Événement collé.");
|
||||
}
|
||||
|
||||
function pasteCopiedEventAtDraftSelection() {
|
||||
if (!copiedEvent) return;
|
||||
if (data.viewMode === "month") {
|
||||
if (!parseLocalDateKey(draft.startDate)) return;
|
||||
pasteEventAtTarget({
|
||||
startDate: draft.startDate,
|
||||
monthlyRepeatBy: draft.monthlyRepeatBy || "monthDay",
|
||||
slotIndex: -1
|
||||
});
|
||||
return;
|
||||
}
|
||||
const targetSelection = getDraftSelection(draft);
|
||||
if (targetSelection) pasteEventAtTarget({ dayIndex: targetSelection.dayIndex, slotIndex: targetSelection.startSlot });
|
||||
}
|
||||
|
||||
function handleSlotPointerDown(dayIndex, slotIndex) {
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
setSelection({ dayIndex, startSlot: slotIndex, endSlot: slotIndex });
|
||||
applySelection(dayIndex, slotIndex);
|
||||
}
|
||||
|
||||
function handleSlotPointerEnter(event, dayIndex, slotIndex) {
|
||||
if (event.buttons !== 1) return;
|
||||
setSelection((current) => {
|
||||
if (!current || current.dayIndex !== dayIndex) return current;
|
||||
applySelection(dayIndex, current.startSlot, slotIndex);
|
||||
return { ...current, endSlot: slotIndex };
|
||||
});
|
||||
}
|
||||
|
||||
function handleMonthPointerDown(dateKey) {
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
setMonthSelection({ startDate: dateKey, endDate: dateKey });
|
||||
applyMonthSelection(dateKey);
|
||||
}
|
||||
|
||||
function handleMonthPointerEnter(event, dateKey) {
|
||||
if (event.buttons !== 1) return;
|
||||
setMonthSelection((current) => {
|
||||
if (!current) return current;
|
||||
applyMonthSelection(current.startDate, dateKey);
|
||||
return { ...current, endDate: dateKey };
|
||||
});
|
||||
}
|
||||
|
||||
function editEvent(event, occurrence = null) {
|
||||
const occurrenceDate = occurrence ? formatLocalDateKey(new Date(occurrence.startMs)) : "";
|
||||
if (occurrenceDate) setDisplayedMonth(getMonthStart(parseLocalDateKey(occurrenceDate)));
|
||||
setDraft({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
dayIndex: event.dayIndex,
|
||||
startDate: occurrenceDate || event.startDate || formatLocalDateKey(now),
|
||||
endDate: occurrenceDate && event.startDate ? formatLocalDateKey(addDays(parseLocalDateKey(occurrenceDate), getDateSpanDays(event.startDate, event.endDate || event.startDate))) : event.endDate || event.startDate || formatLocalDateKey(now),
|
||||
monthlyRepeatBy: event.monthlyRepeatBy || "monthDay",
|
||||
repeat: event.repeat || "none",
|
||||
startTime: minutesToTimeValue(event.startMinutes),
|
||||
endTime: event.endMinutes ? minutesToTimeValue(event.endMinutes) : "",
|
||||
repeatable: event.repeatable !== false,
|
||||
alertMode: event.alertMode || "off",
|
||||
alertOffsetMinutes: event.alertMode === "off" ? [] : normalizeDraftAlertOffsets(event.alertOffsetMinutes),
|
||||
color: event.color || "violet"
|
||||
});
|
||||
setActiveEventId(event.id);
|
||||
setActiveOccurrenceDate(occurrenceDate);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function deleteEvent(eventId) {
|
||||
save({ ...data, initialized: true, events: data.events.filter((event) => event.id !== eventId) });
|
||||
if (draft.id === eventId || activeEventId === eventId) closeForm();
|
||||
}
|
||||
|
||||
function deleteOccurrence(eventId) {
|
||||
const event = data.events.find((item) => item.id === eventId);
|
||||
if (!event || !activeOccurrenceDate || event.repeat === "none") return;
|
||||
const events = data.events.map((item) => item.id === eventId
|
||||
? { ...item, excludedOccurrences: [...new Set([...(item.excludedOccurrences || []), activeOccurrenceDate])].sort() }
|
||||
: item);
|
||||
save({ ...data, initialized: true, events });
|
||||
closeForm();
|
||||
}
|
||||
|
||||
function copyEvent(event) {
|
||||
setCopiedEvent(event);
|
||||
context.notify?.(textContent.copiedTitle || "Événement copié.");
|
||||
}
|
||||
|
||||
function submitEvent(event) {
|
||||
event.preventDefault();
|
||||
const title = draft.title.trim();
|
||||
const dayIndex = Number(draft.dayIndex);
|
||||
const startDate = normalizeDraftDateKey(draft.startDate);
|
||||
const endDate = normalizeDraftDateKey(draft.endDate || draft.startDate);
|
||||
const startMinutes = data.viewMode === "month" ? 0 : timeValueToMinutes(draft.startTime);
|
||||
const endMinutes = data.viewMode === "month" ? -1 : draft.endTime ? timeValueToMinutes(draft.endTime) : -1;
|
||||
const monthlyRepeatBy = normalizeDraftMonthlyRepeatBy(draft.monthlyRepeatBy);
|
||||
const monthRepeat = monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : ["none", "weekly", "monthly"].includes(draft.repeat) ? draft.repeat : "monthly";
|
||||
const validWeekTarget = data.viewMode === "week" && dayIndex >= 0 && dayIndex <= 6;
|
||||
const validMonthTarget = data.viewMode === "month" && startDate && endDate && endDate >= startDate;
|
||||
if (!title || (!validWeekTarget && !validMonthTarget) || startMinutes < 0) {
|
||||
setError(textContent.requiredError || "Titre, date et heure de début requis.");
|
||||
return;
|
||||
}
|
||||
if (draft.endTime && data.viewMode === "week" && endMinutes <= startMinutes) {
|
||||
setError(textContent.invalidRangeError || "La fin doit être après le début.");
|
||||
return;
|
||||
}
|
||||
const alertOffsets = normalizeDraftAlertOffsets(draft.alertOffsetMinutes);
|
||||
const alertMode = alertOffsets.length && CALENDAR_ALERT_MODES.includes(draft.alertMode) ? draft.alertMode : "off";
|
||||
const nextEvent = {
|
||||
id: draft.id || context.uid("calendar"),
|
||||
title,
|
||||
startMinutes,
|
||||
...(data.viewMode === "week" && draft.endTime ? { endMinutes } : {}),
|
||||
repeatable: draft.repeatable !== false,
|
||||
repeat: data.viewMode === "month" ? monthRepeat : draft.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode),
|
||||
alertMode,
|
||||
alertOffsetMinutes: alertOffsets.length ? alertOffsets : [10],
|
||||
color: CALENDAR_COLORS.includes(draft.color) ? draft.color : "violet"
|
||||
};
|
||||
if (data.viewMode === "month") {
|
||||
nextEvent.monthlyRepeatBy = monthlyRepeatBy;
|
||||
nextEvent.startDate = startDate;
|
||||
nextEvent.endDate = endDate;
|
||||
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(startDate));
|
||||
nextEvent.excludedOccurrences = data.events.find((item) => item.id === draft.id)?.excludedOccurrences || [];
|
||||
} else {
|
||||
nextEvent.dayIndex = dayIndex;
|
||||
}
|
||||
const events = draft.id
|
||||
? data.events.map((item) => item.id === draft.id ? nextEvent : item)
|
||||
: [...data.events, nextEvent];
|
||||
save({ ...data, initialized: true, events });
|
||||
closeForm();
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
const target = event.target;
|
||||
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "c") {
|
||||
const activeEvent = data.events.find((item) => item.id === activeEventId);
|
||||
if (!activeEvent) return;
|
||||
event.preventDefault();
|
||||
copyEvent(activeEvent);
|
||||
}
|
||||
if (key === "v") {
|
||||
if (!copiedEvent) return;
|
||||
event.preventDefault();
|
||||
pasteCopiedEventAtDraftSelection();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => setNow(new Date()), 60000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const previousEditing = previousEditingRef.current;
|
||||
previousEditingRef.current = editing;
|
||||
if (!previousEditing && editing) {
|
||||
setDraft(createDefaultDraft());
|
||||
setActiveEventId("");
|
||||
setSelection(null);
|
||||
setError("");
|
||||
}
|
||||
if (previousEditing && !editing) {
|
||||
resetDraft();
|
||||
}
|
||||
}, [editing, resetDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.events.length) return undefined;
|
||||
function pruneExpiredOneShotEvents() {
|
||||
const nowMs = Date.now();
|
||||
const nextEvents = data.events.filter((event) => {
|
||||
if (event.repeatable !== false) return true;
|
||||
const occurrence = getCalendarOccurrences([event], rangeStart, rangeEnd)[0];
|
||||
return !occurrence || occurrence.endMs > nowMs;
|
||||
});
|
||||
if (nextEvents.length === data.events.length) return;
|
||||
save({ ...data, initialized: true, events: nextEvents });
|
||||
if (activeEventId && !nextEvents.some((event) => event.id === activeEventId)) resetDraft();
|
||||
}
|
||||
pruneExpiredOneShotEvents();
|
||||
const intervalId = window.setInterval(pruneExpiredOneShotEvents, 30000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [activeEventId, data, rangeEnd, rangeStart, resetDraft, save]);
|
||||
const controller = useCalendarController({ data, context, toolboxId, moduleId, editing, setEditing, textContent });
|
||||
|
||||
if (!data.initialized) {
|
||||
return (
|
||||
<div className="calendar-module">
|
||||
<section className="calendar-mode-choice" aria-label={textContent.init?.title || "Créer un calendrier"}>
|
||||
<div className="calendar-mode-grid">
|
||||
<button type="button" className="calendar-mode-card" onClick={initializeWeek}>
|
||||
<strong>{textContent.init?.weekly || "Hebdomadaire"}</strong>
|
||||
<span>{textContent.init?.weeklyDescription || "Grille jours par créneaux de 30 minutes."}</span>
|
||||
</button>
|
||||
<button type="button" className="calendar-mode-card" onClick={initializeMonth}>
|
||||
<strong>{textContent.init?.monthly || "Mensuel"}</strong>
|
||||
<span>{textContent.init?.monthlyDescription || "Grille du mois courant."}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CalendarContent
|
||||
data={data}
|
||||
context={context}
|
||||
draft={{ ...draft, alertOffsetMinutes: draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes) }}
|
||||
error={error}
|
||||
activeOccurrenceDate={activeOccurrenceDate}
|
||||
monthStart={monthStart}
|
||||
monthGridDays={monthGridDays}
|
||||
occurrences={occurrences}
|
||||
<CalendarModeChoice
|
||||
textContent={textContent}
|
||||
activeEventId={activeEventId}
|
||||
formOpen={formOpen}
|
||||
draftSelection={draftSelection}
|
||||
selection={selection}
|
||||
monthSelection={monthSelection}
|
||||
days={days}
|
||||
weekGridLayout={weekGridLayout}
|
||||
now={now}
|
||||
nowDayIndex={nowDayIndex}
|
||||
nowMinutes={nowMinutes}
|
||||
hideEmptyHours={hideEmptyHours}
|
||||
textExchangeOpen={textExchangeOpen}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmit={submitEvent}
|
||||
onDraftChange={updateDraft}
|
||||
onAlertModeChange={updateAlertMode}
|
||||
onAlertOffsetToggle={toggleAlertOffset}
|
||||
onDeleteEvent={deleteEvent}
|
||||
onDeleteOccurrence={deleteOccurrence}
|
||||
onClose={closeForm}
|
||||
onDisplayedMonthChange={setDisplayedMonth}
|
||||
onImport={(nextData) => save(nextData)}
|
||||
onHideEmptyHoursToggle={() => setHideEmptyHours((current) => !current)}
|
||||
onMonthPointerDown={handleMonthPointerDown}
|
||||
onMonthPointerEnter={handleMonthPointerEnter}
|
||||
onMonthPointerUp={() => setMonthSelection(null)}
|
||||
onSlotPointerDown={handleSlotPointerDown}
|
||||
onSlotPointerEnter={handleSlotPointerEnter}
|
||||
onSlotPointerUp={() => setSelection(null)}
|
||||
onEditEvent={editEvent}
|
||||
onInitializeWeek={controller.initializeWeek}
|
||||
onInitializeMonth={controller.initializeMonth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <CalendarContent {...controller.contentProps} textExchangeOpen={textExchangeOpen} />;
|
||||
}
|
||||
|
|
|
|||
160
website/src/features/toolboxes/modules/GoalTreeBranch.jsx
Normal file
160
website/src/features/toolboxes/modules/GoalTreeBranch.jsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// Rôle : rend récursivement les branches, cartes et actions de l'arbre d'objectifs.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||
import {
|
||||
getGoalTreeChildren,
|
||||
getGoalTreeParentIds
|
||||
} from "./goalTreeUtils.js";
|
||||
import {
|
||||
getEffectiveGoalTreeProgressMode,
|
||||
getGoalTreeNodeIcon,
|
||||
getGoalTreeNodeIconColor,
|
||||
getGoalTreeNodeTooltip,
|
||||
isSingleValueGoalTreeNode
|
||||
} from "./goalTreeDisplay.js";
|
||||
import { GoalTreeNodeContentPreview, GoalTreeStepLeafControl } from "./GoalTreeNodeContent.jsx";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
157
website/src/features/toolboxes/modules/GoalTreeEditorFields.jsx
Normal file
157
website/src/features/toolboxes/modules/GoalTreeEditorFields.jsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// Rôle : regroupe les champs et pickers utilisés par l'éditeur de noeuds goal tree.
|
||||
import { useState } from "react";
|
||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
|
||||
import {
|
||||
GOAL_TREE_ICON_NAMES,
|
||||
getGoalTreeDescendantIds,
|
||||
getGoalTreeParentIds
|
||||
} from "./goalTreeUtils.js";
|
||||
import { BASE_TOKEN } 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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
// Rôle : rend les contrôles inline et aperçus de contenu des noeuds goal tree.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||
import { formatGoalTreeNumber } from "./goalTreeUtils.js";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,379 +1,13 @@
|
|||
// 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>
|
||||
);
|
||||
}
|
||||
// Rôle : conserve les exports publics historiques des sous-composants goal tree.
|
||||
export { GoalTreeBranch } from "./GoalTreeBranch.jsx";
|
||||
export {
|
||||
GoalTreeContentEditor,
|
||||
GoalTreeFormulaInput,
|
||||
GoalTreeIconPicker,
|
||||
GoalTreePrerequisitePicker,
|
||||
GoalTreeTitleInput
|
||||
} from "./GoalTreeEditorFields.jsx";
|
||||
export {
|
||||
GoalTreeNodeContentPreview,
|
||||
GoalTreeStepLeafControl
|
||||
} from "./GoalTreeNodeContent.jsx";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { GoalTreeBranch } from "./GoalTreeParts.jsx";
|
||||
import { GoalTreeBranch } from "./GoalTreeBranch.jsx";
|
||||
import { getVisibleGoalTreeEdges } from "./goalTreeDisplay.js";
|
||||
|
||||
export function GoalTreeView({
|
||||
|
|
|
|||
199
website/src/features/toolboxes/modules/TaskPlannerBranch.jsx
Normal file
199
website/src/features/toolboxes/modules/TaskPlannerBranch.jsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// Rôle : rend les branches récursives, catégories et zones de drop du task planner.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { TaskPlannerItem } from "./TaskPlannerItem.jsx";
|
||||
import {
|
||||
countCompletedTaskTree,
|
||||
countTaskTree,
|
||||
getTaskTypeLabel,
|
||||
getTasksForParent,
|
||||
getTaskCategoryGroups,
|
||||
isTaskTreeComplete
|
||||
} from "./taskPlannerUtils.js";
|
||||
|
||||
export function TaskPlannerBranch({
|
||||
parentId,
|
||||
parentMap,
|
||||
tasks,
|
||||
data,
|
||||
nowMs,
|
||||
reorder,
|
||||
textContent,
|
||||
openDescriptions,
|
||||
openTaskSettings,
|
||||
prerequisiteNoticeTaskIds,
|
||||
onToggleDescription,
|
||||
onToggleSettings,
|
||||
onPrerequisiteNotice,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
onAddLinkTarget,
|
||||
onRemoveLinkTarget,
|
||||
onSetPrerequisite,
|
||||
onSetTaskCategory,
|
||||
onToggleCategoryCollapsed,
|
||||
visited = new Set()
|
||||
}) {
|
||||
const groupedTasks = getTaskCategoryGroups(tasks, parentMap, parentId, data.categoryOrder);
|
||||
const children = [...groupedTasks.uncategorized, ...groupedTasks.categories.flatMap((group) => group.tasks)].filter((task) => !visited.has(task.id));
|
||||
if (!children.length) return null;
|
||||
const nextVisited = new Set([...visited, ...children.map((task) => task.id)]);
|
||||
const showCategoryBoundaryDropZones = !parentId && reorder.shouldShowGroupBoundaries({ parentId });
|
||||
|
||||
function hasVisibleDescendants(taskId, seen = new Set()) {
|
||||
if (seen.has(taskId)) return false;
|
||||
seen.add(taskId);
|
||||
return getTasksForParent(tasks, parentMap, taskId).some((child) => !child.checked || hasVisibleDescendants(child.id, seen));
|
||||
}
|
||||
|
||||
function renderTask(task) {
|
||||
const nestedChildren = getTasksForParent(tasks, parentMap, task.id).filter((child) => !nextVisited.has(child.id));
|
||||
const nestedBranch = nestedChildren.length ? (
|
||||
<TaskPlannerBranch
|
||||
parentId={task.id}
|
||||
parentMap={parentMap}
|
||||
tasks={tasks}
|
||||
data={data}
|
||||
nowMs={nowMs}
|
||||
reorder={reorder}
|
||||
textContent={textContent}
|
||||
openDescriptions={openDescriptions}
|
||||
openTaskSettings={openTaskSettings}
|
||||
prerequisiteNoticeTaskIds={prerequisiteNoticeTaskIds}
|
||||
onToggleDescription={onToggleDescription}
|
||||
onToggleSettings={onToggleSettings}
|
||||
onPrerequisiteNotice={onPrerequisiteNotice}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onAddLinkTarget={onAddLinkTarget}
|
||||
onRemoveLinkTarget={onRemoveLinkTarget}
|
||||
onSetPrerequisite={onSetPrerequisite}
|
||||
onSetTaskCategory={onSetTaskCategory}
|
||||
onToggleCategoryCollapsed={onToggleCategoryCollapsed}
|
||||
visited={nextVisited}
|
||||
/>
|
||||
) : null;
|
||||
if (data.hideCompleted && task.checked) {
|
||||
if (!hasVisibleDescendants(task.id)) return null;
|
||||
return (
|
||||
<li className="task-planner-hidden-parent" key={`${task.id}:visible-children`}>
|
||||
<div className="task-planner-hidden-parent-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.checked}
|
||||
onChange={(event) => onUpdateTask(task.id, (current) => ({
|
||||
...current,
|
||||
checked: event.target.checked,
|
||||
checkedAt: event.target.checked ? Date.now() : 0
|
||||
}))}
|
||||
aria-label={`${textContent.uncheckTitle || "Marquer non effectué"} ${task.title}`}
|
||||
/>
|
||||
<span title={task.title}>{task.title}</span>
|
||||
<small>{getTaskTypeLabel(task.type, textContent)}</small>
|
||||
</div>
|
||||
<ul className="task-planner-list task-planner-child-list">{nestedBranch}</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TaskPlannerItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
tasks={tasks}
|
||||
data={data}
|
||||
nowMs={nowMs}
|
||||
parentMap={parentMap}
|
||||
parentId={parentId}
|
||||
reorder={reorder}
|
||||
textContent={textContent}
|
||||
descriptionOpen={openDescriptions.has(task.id)}
|
||||
settingsOpen={openTaskSettings.has(task.id)}
|
||||
prerequisiteNoticeVisible={prerequisiteNoticeTaskIds.has(task.id)}
|
||||
onToggleDescription={() => onToggleDescription(task.id)}
|
||||
onToggleSettings={() => onToggleSettings(task.id)}
|
||||
onPrerequisiteNotice={onPrerequisiteNotice}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onAddLinkTarget={onAddLinkTarget}
|
||||
onRemoveLinkTarget={onRemoveLinkTarget}
|
||||
onSetPrerequisite={onSetPrerequisite}
|
||||
onSetTaskCategory={onSetTaskCategory}
|
||||
>
|
||||
{nestedBranch}
|
||||
</TaskPlannerItem>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCategory(group) {
|
||||
const groupTasks = group.tasks.filter((task) => !visited.has(task.id));
|
||||
if (!groupTasks.length) return null;
|
||||
if (data.hideCompleted && isTaskTreeComplete(groupTasks.map((task) => task.id), tasks, parentMap)) return null;
|
||||
const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
|
||||
const groupCompletedCount = countCompletedTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
|
||||
const isCollapsed = data.collapsedCategories.includes(group.category);
|
||||
const sectionClassName = [
|
||||
"task-planner-category-section",
|
||||
groupCompletedCount === groupCount ? "is-complete" : "",
|
||||
isCollapsed ? "is-collapsed" : "",
|
||||
reorder.isGroupDragging(group.category, parentId) ? "is-dragging" : "",
|
||||
reorder.isGroupDropTarget(group.category, parentId) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("group", group.category, parentId) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
const beforeDropZone = showCategoryBoundaryDropZones ? (
|
||||
<li
|
||||
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "before" }) ? "is-drop-target" : ""}`}
|
||||
key={`${parentId || "root"}:${group.category}:before-drop`}
|
||||
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "before" })}
|
||||
/>
|
||||
) : null;
|
||||
const afterDropZone = showCategoryBoundaryDropZones ? (
|
||||
<li
|
||||
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "after" }) ? "is-drop-target" : ""}`}
|
||||
key={`${parentId || "root"}:${group.category}:after-drop`}
|
||||
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "after" })}
|
||||
/>
|
||||
) : null;
|
||||
return [
|
||||
beforeDropZone,
|
||||
<li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} {...reorder.getGroupProps({ groupId: group.category, parentId })}>
|
||||
<div className="checklist-section-header task-planner-category-header">
|
||||
<div className="task-planner-category-title">
|
||||
<button
|
||||
className="task-planner-category-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: group.category, parentId })}
|
||||
aria-label={`${textContent.categoryReorderTitle || "Déplacer la catégorie"} ${group.category}`}
|
||||
title={textContent.categoryReorderTitle || "Déplacer la catégorie"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<h3>{group.category}</h3>
|
||||
</div>
|
||||
<div className="task-planner-category-actions">
|
||||
<span>{groupCompletedCount} / {groupCount}</span>
|
||||
<button
|
||||
className="checklist-section-collapse-button"
|
||||
type="button"
|
||||
onClick={() => onToggleCategoryCollapsed(group.category)}
|
||||
aria-expanded={!isCollapsed}
|
||||
title={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
|
||||
aria-label={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
|
||||
>
|
||||
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<ul className="task-planner-list">
|
||||
{groupTasks.map(renderTask).filter(Boolean)}
|
||||
</ul>
|
||||
)}
|
||||
</li>,
|
||||
afterDropZone
|
||||
];
|
||||
}
|
||||
|
||||
return groupedTasks.entries.map((entry) => {
|
||||
if (entry.type === "task") return visited.has(entry.task.id) ? null : renderTask(entry.task);
|
||||
return renderCategory(entry.group);
|
||||
}).filter(Boolean);
|
||||
}
|
||||
152
website/src/features/toolboxes/modules/TaskPlannerItem.jsx
Normal file
152
website/src/features/toolboxes/modules/TaskPlannerItem.jsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// Rôle : rend une ligne de tâche task planner et ses panneaux associés.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { MissingPrerequisitesTooltip, TaskPlannerItemDetails } from "./TaskPlannerItemPanels.jsx";
|
||||
import {
|
||||
TASK_TYPES,
|
||||
getTaskCategoryDataset,
|
||||
getTaskTypeLabel,
|
||||
isTaskResetSoon
|
||||
} from "./taskPlannerUtils.js";
|
||||
|
||||
export function TaskPlannerItem({
|
||||
task,
|
||||
tasks,
|
||||
data,
|
||||
nowMs,
|
||||
parentMap,
|
||||
parentId,
|
||||
reorder,
|
||||
textContent,
|
||||
descriptionOpen,
|
||||
settingsOpen,
|
||||
prerequisiteNoticeVisible,
|
||||
onToggleDescription,
|
||||
onToggleSettings,
|
||||
onPrerequisiteNotice,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
onAddLinkTarget,
|
||||
onRemoveLinkTarget,
|
||||
onSetPrerequisite,
|
||||
onSetTaskCategory,
|
||||
children
|
||||
}) {
|
||||
const relations = data.relations.filter((relation) => relation.fromTaskId === task.id);
|
||||
const missingPrerequisites = relations.filter((relation) => relation.prerequisite && !tasks.find((item) => item.id === relation.toTaskId)?.checked);
|
||||
const relationTargets = tasks.filter((item) => item.id !== task.id && !relations.some((relation) => relation.toTaskId === item.id));
|
||||
const noticeMessage = prerequisiteNoticeVisible && missingPrerequisites.length > 0 ? textContent.missingPrerequisiteNotice || "Pré requis parent non effectué" : "";
|
||||
const className = [
|
||||
"task-planner-item",
|
||||
task.checked ? "is-complete" : "",
|
||||
isTaskResetSoon(task, data, nowMs) ? "is-reset-soon" : "",
|
||||
missingPrerequisites.length ? "has-missing-prerequisite" : "",
|
||||
reorder.isItemDragging(task.id) ? "is-dragging" : "",
|
||||
reorder.isItemDropTarget(task.id) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("item", task.id) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
const titleEdit = useInlineEdit({
|
||||
value: task.title,
|
||||
transform: (draft) => String(draft || "").trim() || task.title,
|
||||
onCommit: (title) => onUpdateTask(task.id, (current) => ({ ...current, title }))
|
||||
});
|
||||
|
||||
function updateChecked(event) {
|
||||
const checked = event.target.checked;
|
||||
if (checked && missingPrerequisites.length > 0) onPrerequisiteNotice(task.id);
|
||||
onUpdateTask(task.id, (current) => ({
|
||||
...current,
|
||||
checked,
|
||||
checkedAt: checked ? Date.now() : 0
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={className} {...reorder.getItemProps({ itemId: task.id, groupId: getTaskCategoryDataset(task, tasks, parentMap), parentId })}>
|
||||
<div className="task-planner-line">
|
||||
<button
|
||||
className="task-planner-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, task.id)}
|
||||
aria-label={`${textContent.reorderTitle || "Déplacer"} ${task.title}`}
|
||||
title={textContent.reorderTitle || "Déplacer"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.checked}
|
||||
onChange={updateChecked}
|
||||
aria-label={`${task.checked ? textContent.uncheckTitle || "Marquer non effectué" : textContent.checkTitle || "Marquer effectué"} ${task.title}`}
|
||||
/>
|
||||
<input
|
||||
{...titleEdit.getInputProps({
|
||||
className: "tool-split-entry-label task-planner-title-input",
|
||||
"aria-label": textContent.titleLabel || "Titre"
|
||||
})}
|
||||
/>
|
||||
<select
|
||||
className="task-planner-type-select"
|
||||
value={task.type}
|
||||
onChange={(event) => onUpdateTask(task.id, (current) => {
|
||||
const nextType = event.target.value;
|
||||
const nextTask = { ...current, type: nextType };
|
||||
if (nextType !== "weekly") delete nextTask.weeklyResetDay;
|
||||
if (nextType !== "daily") delete nextTask.dailyResetTime;
|
||||
return nextTask;
|
||||
})}
|
||||
aria-label={textContent.typeLabel || "Type"}
|
||||
>
|
||||
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
|
||||
</select>
|
||||
{missingPrerequisites.length > 0 && (
|
||||
<MissingPrerequisitesTooltip missingPrerequisites={missingPrerequisites} tasks={tasks} textContent={textContent} />
|
||||
)}
|
||||
<div className="task-planner-actions">
|
||||
<button
|
||||
className={`task-planner-dropdown-button ${descriptionOpen ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleDescription}
|
||||
aria-expanded={descriptionOpen}
|
||||
aria-label={`${descriptionOpen ? textContent.hideDescriptionTitle || "Masquer la description" : textContent.showDescriptionTitle || "Afficher la description"} ${task.title}`}
|
||||
title={descriptionOpen ? textContent.hideDescriptionTitle || "Masquer la description" : textContent.showDescriptionTitle || "Afficher la description"}
|
||||
>
|
||||
<Icon name={descriptionOpen ? "chevron-up" : "chevron-down"} />
|
||||
</button>
|
||||
<button
|
||||
className={`task-planner-settings-button ${settingsOpen ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleSettings}
|
||||
aria-expanded={settingsOpen}
|
||||
aria-label={`${textContent.taskSettingsTitle || "Réglages de la tâche"} ${task.title}`}
|
||||
title={textContent.taskSettingsTitle || "Réglages de la tâche"}
|
||||
>
|
||||
<Icon name="settings" />
|
||||
</button>
|
||||
<button className="task-planner-delete-button danger" type="button" onClick={() => onDeleteTask(task.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${task.title}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TaskPlannerItemDetails
|
||||
task={task}
|
||||
tasks={tasks}
|
||||
data={data}
|
||||
parentMap={parentMap}
|
||||
parentId={parentId}
|
||||
textContent={textContent}
|
||||
descriptionOpen={descriptionOpen}
|
||||
settingsOpen={settingsOpen}
|
||||
noticeMessage={noticeMessage}
|
||||
relations={relations}
|
||||
relationTargets={relationTargets}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onAddLinkTarget={onAddLinkTarget}
|
||||
onRemoveLinkTarget={onRemoveLinkTarget}
|
||||
onSetPrerequisite={onSetPrerequisite}
|
||||
onSetTaskCategory={onSetTaskCategory}
|
||||
/>
|
||||
{children && <ul className="task-planner-list task-planner-child-list">{children}</ul>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,346 +1,3 @@
|
|||
// Rôle : rend les branches et lignes de tâches du task planner.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { MissingPrerequisitesTooltip, TaskPlannerItemDetails } from "./TaskPlannerItemPanels.jsx";
|
||||
import {
|
||||
TASK_TYPES,
|
||||
countCompletedTaskTree,
|
||||
countTaskTree,
|
||||
getTaskCategoryDataset,
|
||||
getTaskCategoryGroups,
|
||||
getTaskTypeLabel,
|
||||
getTasksForParent,
|
||||
isTaskResetSoon,
|
||||
isTaskTreeComplete
|
||||
} from "./taskPlannerUtils.js";
|
||||
|
||||
export function TaskPlannerBranch({
|
||||
parentId,
|
||||
parentMap,
|
||||
tasks,
|
||||
data,
|
||||
nowMs,
|
||||
reorder,
|
||||
textContent,
|
||||
openDescriptions,
|
||||
openTaskSettings,
|
||||
prerequisiteNoticeTaskIds,
|
||||
onToggleDescription,
|
||||
onToggleSettings,
|
||||
onPrerequisiteNotice,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
onAddLinkTarget,
|
||||
onRemoveLinkTarget,
|
||||
onSetPrerequisite,
|
||||
onSetTaskCategory,
|
||||
onToggleCategoryCollapsed,
|
||||
visited = new Set()
|
||||
}) {
|
||||
const groupedTasks = getTaskCategoryGroups(tasks, parentMap, parentId, data.categoryOrder);
|
||||
const children = [...groupedTasks.uncategorized, ...groupedTasks.categories.flatMap((group) => group.tasks)].filter((task) => !visited.has(task.id));
|
||||
if (!children.length) return null;
|
||||
const nextVisited = new Set([...visited, ...children.map((task) => task.id)]);
|
||||
const showCategoryBoundaryDropZones = !parentId && reorder.shouldShowGroupBoundaries({ parentId });
|
||||
|
||||
function hasVisibleDescendants(taskId, seen = new Set()) {
|
||||
if (seen.has(taskId)) return false;
|
||||
seen.add(taskId);
|
||||
return getTasksForParent(tasks, parentMap, taskId).some((child) => !child.checked || hasVisibleDescendants(child.id, seen));
|
||||
}
|
||||
|
||||
function renderTask(task) {
|
||||
const nestedChildren = getTasksForParent(tasks, parentMap, task.id).filter((child) => !nextVisited.has(child.id));
|
||||
const nestedBranch = nestedChildren.length ? (
|
||||
<TaskPlannerBranch
|
||||
parentId={task.id}
|
||||
parentMap={parentMap}
|
||||
tasks={tasks}
|
||||
data={data}
|
||||
nowMs={nowMs}
|
||||
reorder={reorder}
|
||||
textContent={textContent}
|
||||
openDescriptions={openDescriptions}
|
||||
openTaskSettings={openTaskSettings}
|
||||
prerequisiteNoticeTaskIds={prerequisiteNoticeTaskIds}
|
||||
onToggleDescription={onToggleDescription}
|
||||
onToggleSettings={onToggleSettings}
|
||||
onPrerequisiteNotice={onPrerequisiteNotice}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onAddLinkTarget={onAddLinkTarget}
|
||||
onRemoveLinkTarget={onRemoveLinkTarget}
|
||||
onSetPrerequisite={onSetPrerequisite}
|
||||
onSetTaskCategory={onSetTaskCategory}
|
||||
onToggleCategoryCollapsed={onToggleCategoryCollapsed}
|
||||
visited={nextVisited}
|
||||
/>
|
||||
) : null;
|
||||
if (data.hideCompleted && task.checked) {
|
||||
if (!hasVisibleDescendants(task.id)) return null;
|
||||
return (
|
||||
<li className="task-planner-hidden-parent" key={`${task.id}:visible-children`}>
|
||||
<div className="task-planner-hidden-parent-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.checked}
|
||||
onChange={(event) => onUpdateTask(task.id, (current) => ({
|
||||
...current,
|
||||
checked: event.target.checked,
|
||||
checkedAt: event.target.checked ? Date.now() : 0
|
||||
}))}
|
||||
aria-label={`${textContent.uncheckTitle || "Marquer non effectué"} ${task.title}`}
|
||||
/>
|
||||
<span title={task.title}>{task.title}</span>
|
||||
<small>{getTaskTypeLabel(task.type, textContent)}</small>
|
||||
</div>
|
||||
<ul className="task-planner-list task-planner-child-list">{nestedBranch}</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TaskPlannerItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
tasks={tasks}
|
||||
data={data}
|
||||
nowMs={nowMs}
|
||||
parentMap={parentMap}
|
||||
parentId={parentId}
|
||||
reorder={reorder}
|
||||
textContent={textContent}
|
||||
descriptionOpen={openDescriptions.has(task.id)}
|
||||
settingsOpen={openTaskSettings.has(task.id)}
|
||||
prerequisiteNoticeVisible={prerequisiteNoticeTaskIds.has(task.id)}
|
||||
onToggleDescription={() => onToggleDescription(task.id)}
|
||||
onToggleSettings={() => onToggleSettings(task.id)}
|
||||
onPrerequisiteNotice={onPrerequisiteNotice}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onAddLinkTarget={onAddLinkTarget}
|
||||
onRemoveLinkTarget={onRemoveLinkTarget}
|
||||
onSetPrerequisite={onSetPrerequisite}
|
||||
onSetTaskCategory={onSetTaskCategory}
|
||||
>
|
||||
{nestedBranch}
|
||||
</TaskPlannerItem>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCategory(group) {
|
||||
const groupTasks = group.tasks.filter((task) => !visited.has(task.id));
|
||||
if (!groupTasks.length) return null;
|
||||
if (data.hideCompleted && isTaskTreeComplete(groupTasks.map((task) => task.id), tasks, parentMap)) return null;
|
||||
const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
|
||||
const groupCompletedCount = countCompletedTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
|
||||
const isCollapsed = data.collapsedCategories.includes(group.category);
|
||||
const sectionClassName = [
|
||||
"task-planner-category-section",
|
||||
groupCompletedCount === groupCount ? "is-complete" : "",
|
||||
isCollapsed ? "is-collapsed" : "",
|
||||
reorder.isGroupDragging(group.category, parentId) ? "is-dragging" : "",
|
||||
reorder.isGroupDropTarget(group.category, parentId) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("group", group.category, parentId) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
const beforeDropZone = showCategoryBoundaryDropZones ? (
|
||||
<li
|
||||
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "before" }) ? "is-drop-target" : ""}`}
|
||||
key={`${parentId || "root"}:${group.category}:before-drop`}
|
||||
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "before" })}
|
||||
/>
|
||||
) : null;
|
||||
const afterDropZone = showCategoryBoundaryDropZones ? (
|
||||
<li
|
||||
className={`task-planner-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: group.category, parentId, placement: "after" }) ? "is-drop-target" : ""}`}
|
||||
key={`${parentId || "root"}:${group.category}:after-drop`}
|
||||
{...reorder.getGroupBoundaryProps({ groupId: group.category, parentId, placement: "after" })}
|
||||
/>
|
||||
) : null;
|
||||
return [
|
||||
beforeDropZone,
|
||||
<li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} {...reorder.getGroupProps({ groupId: group.category, parentId })}>
|
||||
<div className="checklist-section-header task-planner-category-header">
|
||||
<div className="task-planner-category-title">
|
||||
<button
|
||||
className="task-planner-category-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: group.category, parentId })}
|
||||
aria-label={`${textContent.categoryReorderTitle || "Déplacer la catégorie"} ${group.category}`}
|
||||
title={textContent.categoryReorderTitle || "Déplacer la catégorie"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<h3>{group.category}</h3>
|
||||
</div>
|
||||
<div className="task-planner-category-actions">
|
||||
<span>{groupCompletedCount} / {groupCount}</span>
|
||||
<button
|
||||
className="checklist-section-collapse-button"
|
||||
type="button"
|
||||
onClick={() => onToggleCategoryCollapsed(group.category)}
|
||||
aria-expanded={!isCollapsed}
|
||||
title={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
|
||||
aria-label={isCollapsed ? textContent.showCategoryTitle || "Afficher cette catégorie" : textContent.hideCategoryTitle || "Réduire cette catégorie"}
|
||||
>
|
||||
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<ul className="task-planner-list">
|
||||
{groupTasks.map(renderTask).filter(Boolean)}
|
||||
</ul>
|
||||
)}
|
||||
</li>,
|
||||
afterDropZone
|
||||
];
|
||||
}
|
||||
|
||||
const entries = groupedTasks.entries.map((entry) => {
|
||||
if (entry.type === "task") return visited.has(entry.task.id) ? null : renderTask(entry.task);
|
||||
return renderCategory(entry.group);
|
||||
}).filter(Boolean);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function TaskPlannerItem({
|
||||
task,
|
||||
tasks,
|
||||
data,
|
||||
nowMs,
|
||||
parentMap,
|
||||
parentId,
|
||||
reorder,
|
||||
textContent,
|
||||
descriptionOpen,
|
||||
settingsOpen,
|
||||
prerequisiteNoticeVisible,
|
||||
onToggleDescription,
|
||||
onToggleSettings,
|
||||
onPrerequisiteNotice,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
onAddLinkTarget,
|
||||
onRemoveLinkTarget,
|
||||
onSetPrerequisite,
|
||||
onSetTaskCategory,
|
||||
children
|
||||
}) {
|
||||
const relations = data.relations.filter((relation) => relation.fromTaskId === task.id);
|
||||
const missingPrerequisites = relations.filter((relation) => relation.prerequisite && !tasks.find((item) => item.id === relation.toTaskId)?.checked);
|
||||
const relationTargets = tasks.filter((item) => item.id !== task.id && !relations.some((relation) => relation.toTaskId === item.id));
|
||||
const noticeMessage = prerequisiteNoticeVisible && missingPrerequisites.length > 0 ? textContent.missingPrerequisiteNotice || "Pré requis parent non effectué" : "";
|
||||
const className = [
|
||||
"task-planner-item",
|
||||
task.checked ? "is-complete" : "",
|
||||
isTaskResetSoon(task, data, nowMs) ? "is-reset-soon" : "",
|
||||
missingPrerequisites.length ? "has-missing-prerequisite" : "",
|
||||
reorder.isItemDragging(task.id) ? "is-dragging" : "",
|
||||
reorder.isItemDropTarget(task.id) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("item", task.id) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
const titleEdit = useInlineEdit({
|
||||
value: task.title,
|
||||
transform: (draft) => String(draft || "").trim() || task.title,
|
||||
onCommit: (title) => onUpdateTask(task.id, (current) => ({ ...current, title }))
|
||||
});
|
||||
|
||||
function updateChecked(event) {
|
||||
const checked = event.target.checked;
|
||||
if (checked && missingPrerequisites.length > 0) onPrerequisiteNotice(task.id);
|
||||
onUpdateTask(task.id, (current) => ({
|
||||
...current,
|
||||
checked,
|
||||
checkedAt: checked ? Date.now() : 0
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={className} {...reorder.getItemProps({ itemId: task.id, groupId: getTaskCategoryDataset(task, tasks, parentMap), parentId })}>
|
||||
<div className="task-planner-line">
|
||||
<button
|
||||
className="task-planner-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, task.id)}
|
||||
aria-label={`${textContent.reorderTitle || "Déplacer"} ${task.title}`}
|
||||
title={textContent.reorderTitle || "Déplacer"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.checked}
|
||||
onChange={updateChecked}
|
||||
aria-label={`${task.checked ? textContent.uncheckTitle || "Marquer non effectué" : textContent.checkTitle || "Marquer effectué"} ${task.title}`}
|
||||
/>
|
||||
<input
|
||||
{...titleEdit.getInputProps({
|
||||
className: "tool-split-entry-label task-planner-title-input",
|
||||
"aria-label": textContent.titleLabel || "Titre"
|
||||
})}
|
||||
/>
|
||||
<select
|
||||
className="task-planner-type-select"
|
||||
value={task.type}
|
||||
onChange={(event) => onUpdateTask(task.id, (current) => {
|
||||
const nextType = event.target.value;
|
||||
const nextTask = { ...current, type: nextType };
|
||||
if (nextType !== "weekly") delete nextTask.weeklyResetDay;
|
||||
if (nextType !== "daily") delete nextTask.dailyResetTime;
|
||||
return nextTask;
|
||||
})}
|
||||
aria-label={textContent.typeLabel || "Type"}
|
||||
>
|
||||
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
|
||||
</select>
|
||||
{missingPrerequisites.length > 0 && (
|
||||
<MissingPrerequisitesTooltip missingPrerequisites={missingPrerequisites} tasks={tasks} textContent={textContent} />
|
||||
)}
|
||||
<div className="task-planner-actions">
|
||||
<button
|
||||
className={`task-planner-dropdown-button ${descriptionOpen ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleDescription}
|
||||
aria-expanded={descriptionOpen}
|
||||
aria-label={`${descriptionOpen ? textContent.hideDescriptionTitle || "Masquer la description" : textContent.showDescriptionTitle || "Afficher la description"} ${task.title}`}
|
||||
title={descriptionOpen ? textContent.hideDescriptionTitle || "Masquer la description" : textContent.showDescriptionTitle || "Afficher la description"}
|
||||
>
|
||||
<Icon name={descriptionOpen ? "chevron-up" : "chevron-down"} />
|
||||
</button>
|
||||
<button
|
||||
className={`task-planner-settings-button ${settingsOpen ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleSettings}
|
||||
aria-expanded={settingsOpen}
|
||||
aria-label={`${textContent.taskSettingsTitle || "Réglages de la tâche"} ${task.title}`}
|
||||
title={textContent.taskSettingsTitle || "Réglages de la tâche"}
|
||||
>
|
||||
<Icon name="settings" />
|
||||
</button>
|
||||
<button className="task-planner-delete-button danger" type="button" onClick={() => onDeleteTask(task.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${task.title}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TaskPlannerItemDetails
|
||||
task={task}
|
||||
tasks={tasks}
|
||||
data={data}
|
||||
parentMap={parentMap}
|
||||
parentId={parentId}
|
||||
textContent={textContent}
|
||||
descriptionOpen={descriptionOpen}
|
||||
settingsOpen={settingsOpen}
|
||||
noticeMessage={noticeMessage}
|
||||
relations={relations}
|
||||
relationTargets={relationTargets}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onAddLinkTarget={onAddLinkTarget}
|
||||
onRemoveLinkTarget={onRemoveLinkTarget}
|
||||
onSetPrerequisite={onSetPrerequisite}
|
||||
onSetTaskCategory={onSetTaskCategory}
|
||||
/>
|
||||
{children && <ul className="task-planner-list task-planner-child-list">{children}</ul>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
// Rôle : conserve les exports publics historiques des sous-composants task planner.
|
||||
export { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
|
||||
export { TaskPlannerItem } from "./TaskPlannerItem.jsx";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { TaskPlannerBranch } from "./TaskPlannerItems.jsx";
|
||||
import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
|
||||
import { TaskPlannerSettings } from "./TaskPlannerSettings.jsx";
|
||||
import { TaskPlannerTopbar } from "./TaskPlannerTopbar.jsx";
|
||||
import {
|
||||
|
|
|
|||
196
website/src/features/toolboxes/modules/TimerCountdown.jsx
Normal file
196
website/src/features/toolboxes/modules/TimerCountdown.jsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// Rôle : rend le formulaire et la liste des comptes à rebours timer.
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { formatTargetTime } from "./timerUtils.js";
|
||||
import {
|
||||
AUTO_REFRESH_COUNTDOWN_TYPES,
|
||||
TOGGLE_REFRESH_COUNTDOWN_TYPES,
|
||||
canToggleAutoRefresh,
|
||||
getAlertGuardMessage,
|
||||
getCountdownMetaParts,
|
||||
handleEditableKeyDown,
|
||||
isAlertGuarded
|
||||
} from "./timerOptions.js";
|
||||
import { EditableTimerLabel, TimerAlertModeSwitch, TimerValue, TimePartsInput } from "./TimerSharedControls.jsx";
|
||||
|
||||
export function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
|
||||
return (
|
||||
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit} aria-describedby={error ? "timer-countdown-error" : undefined}>
|
||||
<label>
|
||||
<span>{textContent.countdownLabel || "Libellé"}</span>
|
||||
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.countdownTypeLabel || "Type"}</span>
|
||||
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
|
||||
<option value="duration">{textContent.durationType || "Durée"}</option>
|
||||
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
|
||||
<option value="time_pattern">{textContent.timePatternType || "Pattern horaire"}</option>
|
||||
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.type === "duration" && (
|
||||
<label>
|
||||
<span className="timer-field-spacer" aria-hidden="true" />
|
||||
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
|
||||
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
|
||||
</label>
|
||||
)}
|
||||
{form.type === "daily_time" && (
|
||||
<label>
|
||||
<span className="timer-field-spacer" aria-hidden="true" />
|
||||
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
|
||||
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
|
||||
</label>
|
||||
)}
|
||||
{form.type === "time_pattern" && (
|
||||
<label>
|
||||
<span className="timer-field-spacer" aria-hidden="true" />
|
||||
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
|
||||
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
|
||||
</label>
|
||||
)}
|
||||
{form.type === "interval" && (
|
||||
<div className="timer-interval-fields">
|
||||
<label>
|
||||
<span>{textContent.intervalLabel || "Toutes les"}</span>
|
||||
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.intervalStartTimeLabel || "Départ"}</span>
|
||||
<TimePartsInput value={form.intervalStartTime} onChange={(intervalStartTime) => onChange({ intervalStartTime })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p id="timer-countdown-error" className="timer-form-error" role="alert">
|
||||
<Icon name="close" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
|
||||
const [editingId, setEditingId] = useState("");
|
||||
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
|
||||
return (
|
||||
<ul className="tool-split-entry-list timer-list">
|
||||
{countdowns.map((countdown) => {
|
||||
const remainingMs = countdown.targetMs - nowMs;
|
||||
const expired = remainingMs <= 0;
|
||||
const canReset = countdown.type === "duration";
|
||||
const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
|
||||
const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
|
||||
const refreshTitle = refreshBlocked
|
||||
? getAlertGuardMessage(textContent)
|
||||
: textContent.autoRefreshTitle || "Répéter automatiquement";
|
||||
const className = [
|
||||
"timer-list-item",
|
||||
expired ? "is-expired" : "",
|
||||
editingId === countdown.id ? "is-editing" : "",
|
||||
reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
|
||||
reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
|
||||
reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
|
||||
|
||||
return (
|
||||
<li key={countdown.id} className={className} {...reorderProps}>
|
||||
<div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${(canReset || canToggleRefresh) && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
|
||||
{reorderEnabled && (
|
||||
<button
|
||||
className="timer-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, countdown.id)}
|
||||
aria-label={`${textContent.reorderTitle || "Déplacer"} ${countdown.label}`}
|
||||
title={textContent.reorderTitle || "Déplacer"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
)}
|
||||
{editingId === countdown.id ? (
|
||||
<EditableTimerLabel
|
||||
value={countdown.label}
|
||||
placeholder={textContent.defaultCountdownLabel || "Timer"}
|
||||
ariaLabel={textContent.countdownLabel || "Libellé"}
|
||||
onRename={(label) => onRename(countdown.id, label)}
|
||||
onDone={() => setEditingId("")}
|
||||
>
|
||||
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
||||
</EditableTimerLabel>
|
||||
) : (
|
||||
<span
|
||||
className="tool-split-entry-summary timer-entry-summary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setEditingId(countdown.id)}
|
||||
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
|
||||
>
|
||||
<span className="timer-entry-line">
|
||||
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
||||
<span>{countdown.label}</span>
|
||||
<span className="timer-inline-controls">
|
||||
{canReset && (
|
||||
<button
|
||||
className="timer-inline-reset"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onReset(countdown);
|
||||
}}
|
||||
aria-label={textContent.resetTitle || "Réinitialiser"}
|
||||
title={textContent.resetTitle || "Réinitialiser"}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
)}
|
||||
{canToggleRefresh && (
|
||||
<button
|
||||
className={`timer-inline-refresh-toggle ${countdown.autoRefresh ? "active" : ""} ${refreshBlocked ? "is-blocked" : ""}`}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAutoRefreshToggle(countdown.id);
|
||||
}}
|
||||
disabled={refreshBlocked}
|
||||
aria-pressed={Boolean(countdown.autoRefresh)}
|
||||
aria-label={refreshTitle}
|
||||
title={refreshTitle}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<TimerAlertModeSwitch
|
||||
mode={countdown.alertMode}
|
||||
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
|
||||
textContent={textContent}
|
||||
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="timer-entry-meta">
|
||||
<span>
|
||||
{getCountdownMetaParts(countdown, textContent).map((part, index) => (
|
||||
<b key={`${part}-${index}`}>{part}</b>
|
||||
))}
|
||||
</span>
|
||||
<em>
|
||||
<b>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}</b>
|
||||
</em>
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,8 @@ import {
|
|||
timePartsToString
|
||||
} from "./timerUtils.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import { CountdownControls, CountdownList, StopwatchControls, StopwatchLapList } from "./TimerParts.jsx";
|
||||
import { CountdownControls, CountdownList } from "./TimerCountdown.jsx";
|
||||
import { StopwatchControls, StopwatchLapList } from "./TimerStopwatch.jsx";
|
||||
import {
|
||||
ALERT_MIN_DELAY_MS,
|
||||
AUTO_REFRESH_COUNTDOWN_TYPES,
|
||||
|
|
|
|||
|
|
@ -1,350 +1,4 @@
|
|||
// Rôle : rend les contrôles, valeurs et listes de résultats du module Timer.
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { formatDuration, formatDurationWithCentiseconds, formatTargetTime, getCentiseconds } from "./timerUtils.js";
|
||||
import {
|
||||
ALERT_MODES,
|
||||
AUTO_REFRESH_COUNTDOWN_TYPES,
|
||||
TOGGLE_REFRESH_COUNTDOWN_TYPES,
|
||||
canToggleAutoRefresh,
|
||||
getAlertGuardMessage,
|
||||
getCountdownMetaParts,
|
||||
handleEditableKeyDown,
|
||||
isAlertGuarded
|
||||
} from "./timerOptions.js";
|
||||
|
||||
export function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
|
||||
return (
|
||||
<div className="timer-control-panel" role="tabpanel">
|
||||
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
|
||||
<strong><TimerValue ms={elapsedMs} compact /></strong>
|
||||
<div className="timer-actions">
|
||||
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
|
||||
<Icon name={running ? "pause-circle" : "play-circle"} />
|
||||
</button>
|
||||
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
|
||||
<Icon name="record-circle" />
|
||||
</button>
|
||||
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
|
||||
<Icon name="stop-circle" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
|
||||
return (
|
||||
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit} aria-describedby={error ? "timer-countdown-error" : undefined}>
|
||||
<label>
|
||||
<span>{textContent.countdownLabel || "Libellé"}</span>
|
||||
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.countdownTypeLabel || "Type"}</span>
|
||||
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
|
||||
<option value="duration">{textContent.durationType || "Durée"}</option>
|
||||
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
|
||||
<option value="time_pattern">{textContent.timePatternType || "Pattern horaire"}</option>
|
||||
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.type === "duration" && (
|
||||
<label>
|
||||
<span className="timer-field-spacer" aria-hidden="true" />
|
||||
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
|
||||
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
|
||||
</label>
|
||||
)}
|
||||
{form.type === "daily_time" && (
|
||||
<label>
|
||||
<span className="timer-field-spacer" aria-hidden="true" />
|
||||
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
|
||||
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
|
||||
</label>
|
||||
)}
|
||||
{form.type === "time_pattern" && (
|
||||
<label>
|
||||
<span className="timer-field-spacer" aria-hidden="true" />
|
||||
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
|
||||
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
|
||||
</label>
|
||||
)}
|
||||
{form.type === "interval" && (
|
||||
<div className="timer-interval-fields">
|
||||
<label>
|
||||
<span>{textContent.intervalLabel || "Toutes les"}</span>
|
||||
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.intervalStartTimeLabel || "Départ"}</span>
|
||||
<TimePartsInput value={form.intervalStartTime} onChange={(intervalStartTime) => onChange({ intervalStartTime })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p id="timer-countdown-error" className="timer-form-error" role="alert">
|
||||
<Icon name="close" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
|
||||
const [editingId, setEditingId] = useState("");
|
||||
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
|
||||
return (
|
||||
<ul className="tool-split-entry-list timer-list">
|
||||
{laps.map((lap, index) => (
|
||||
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
|
||||
<div className="tool-split-entry timer-entry has-single-action">
|
||||
{editingId === lap.id ? (
|
||||
<EditableTimerLabel
|
||||
value={lap.label}
|
||||
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
|
||||
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
|
||||
onRename={(label) => onRename(lap.id, label)}
|
||||
onDone={() => setEditingId("")}
|
||||
>
|
||||
<TimerValue ms={lap.elapsedMs} />
|
||||
</EditableTimerLabel>
|
||||
) : (
|
||||
<span
|
||||
className="tool-split-entry-summary timer-entry-summary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setEditingId(lap.id)}
|
||||
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
|
||||
>
|
||||
<span className="timer-entry-line">
|
||||
<TimerValue ms={lap.elapsedMs} />
|
||||
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
|
||||
const [editingId, setEditingId] = useState("");
|
||||
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
|
||||
return (
|
||||
<ul className="tool-split-entry-list timer-list">
|
||||
{countdowns.map((countdown) => {
|
||||
const remainingMs = countdown.targetMs - nowMs;
|
||||
const expired = remainingMs <= 0;
|
||||
const canReset = countdown.type === "duration";
|
||||
const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
|
||||
const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
|
||||
const refreshTitle = refreshBlocked
|
||||
? getAlertGuardMessage(textContent)
|
||||
: textContent.autoRefreshTitle || "Répéter automatiquement";
|
||||
const className = [
|
||||
"timer-list-item",
|
||||
expired ? "is-expired" : "",
|
||||
editingId === countdown.id ? "is-editing" : "",
|
||||
reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
|
||||
reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
|
||||
reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
|
||||
|
||||
return (
|
||||
<li key={countdown.id} className={className} {...reorderProps}>
|
||||
<div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${(canReset || canToggleRefresh) && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
|
||||
{reorderEnabled && (
|
||||
<button
|
||||
className="timer-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, countdown.id)}
|
||||
aria-label={`${textContent.reorderTitle || "Déplacer"} ${countdown.label}`}
|
||||
title={textContent.reorderTitle || "Déplacer"}
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
)}
|
||||
{editingId === countdown.id ? (
|
||||
<EditableTimerLabel
|
||||
value={countdown.label}
|
||||
placeholder={textContent.defaultCountdownLabel || "Timer"}
|
||||
ariaLabel={textContent.countdownLabel || "Libellé"}
|
||||
onRename={(label) => onRename(countdown.id, label)}
|
||||
onDone={() => setEditingId("")}
|
||||
>
|
||||
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
||||
</EditableTimerLabel>
|
||||
) : (
|
||||
<span
|
||||
className="tool-split-entry-summary timer-entry-summary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setEditingId(countdown.id)}
|
||||
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
|
||||
>
|
||||
<span className="timer-entry-line">
|
||||
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
||||
<span>{countdown.label}</span>
|
||||
<span className="timer-inline-controls">
|
||||
{canReset && (
|
||||
<button
|
||||
className="timer-inline-reset"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onReset(countdown);
|
||||
}}
|
||||
aria-label={textContent.resetTitle || "Réinitialiser"}
|
||||
title={textContent.resetTitle || "Réinitialiser"}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
)}
|
||||
{canToggleRefresh && (
|
||||
<button
|
||||
className={`timer-inline-refresh-toggle ${countdown.autoRefresh ? "active" : ""} ${refreshBlocked ? "is-blocked" : ""}`}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAutoRefreshToggle(countdown.id);
|
||||
}}
|
||||
disabled={refreshBlocked}
|
||||
aria-pressed={Boolean(countdown.autoRefresh)}
|
||||
aria-label={refreshTitle}
|
||||
title={refreshTitle}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<TimerAlertModeSwitch
|
||||
mode={countdown.alertMode}
|
||||
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
|
||||
textContent={textContent}
|
||||
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="timer-entry-meta">
|
||||
<span>
|
||||
{getCountdownMetaParts(countdown, textContent).map((part, index) => (
|
||||
<b key={`${part}-${index}`}>{part}</b>
|
||||
))}
|
||||
</span>
|
||||
<em>
|
||||
<b>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}</b>
|
||||
</em>
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function TimerValue({ ms, compact = false, showCentiseconds = true }) {
|
||||
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
|
||||
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
|
||||
|
||||
return (
|
||||
<span className="timer-value is-compact">
|
||||
<span>{formatDuration(ms)}</span>
|
||||
<small>{getCentiseconds(ms)}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
|
||||
const labels = {
|
||||
off: textContent.alertOffTitle || "Pas d'alerte",
|
||||
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
|
||||
site: textContent.alertSiteTitle || "Alerte sur tout le site"
|
||||
};
|
||||
const blockedTitle = getAlertGuardMessage(textContent);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
|
||||
role="group"
|
||||
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
|
||||
title={blocked ? blockedTitle : undefined}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{ALERT_MODES.map((option) => {
|
||||
const label = labels[option.mode];
|
||||
const disabled = Boolean(blocked);
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
className={`timer-alert-mode-button ${!blocked && mode === option.mode ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onChange(option.mode)}
|
||||
disabled={disabled}
|
||||
aria-pressed={mode === option.mode}
|
||||
aria-label={disabled ? blockedTitle : label}
|
||||
title={disabled ? blockedTitle : label}
|
||||
>
|
||||
<Icon name={option.icon} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimePartsInput({ value, onChange, allowWildcard = false }) {
|
||||
function updatePart(part, nextValue) {
|
||||
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
|
||||
onChange({ ...value, [part]: cleanValue });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
|
||||
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||
<span>:</span>
|
||||
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||
<span>:</span>
|
||||
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
|
||||
const inlineEdit = useInlineEdit({
|
||||
value,
|
||||
onCommit: onRename,
|
||||
onDone,
|
||||
autoFocus: true,
|
||||
commitUnchanged: true
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
|
||||
<input
|
||||
{...inlineEdit.getInputProps({
|
||||
className: "tool-split-entry-label timer-lap-label",
|
||||
placeholder,
|
||||
"aria-label": ariaLabel
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Rôle : conserve les exports publics historiques des sous-composants timer.
|
||||
export { CountdownControls, CountdownList } from "./TimerCountdown.jsx";
|
||||
export { EditableTimerLabel, TimerAlertModeSwitch, TimerValue, TimePartsInput } from "./TimerSharedControls.jsx";
|
||||
export { StopwatchControls, StopwatchLapList } from "./TimerStopwatch.jsx";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
// Rôle : regroupe les contrôles partagés entre chronomètre et comptes à rebours.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { formatDuration, formatDurationWithCentiseconds, getCentiseconds } from "./timerUtils.js";
|
||||
import { ALERT_MODES, getAlertGuardMessage } from "./timerOptions.js";
|
||||
|
||||
export function TimerValue({ ms, compact = false, showCentiseconds = true }) {
|
||||
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
|
||||
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
|
||||
|
||||
return (
|
||||
<span className="timer-value is-compact">
|
||||
<span>{formatDuration(ms)}</span>
|
||||
<small>{getCentiseconds(ms)}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
|
||||
const labels = {
|
||||
off: textContent.alertOffTitle || "Pas d'alerte",
|
||||
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
|
||||
site: textContent.alertSiteTitle || "Alerte sur tout le site"
|
||||
};
|
||||
const blockedTitle = getAlertGuardMessage(textContent);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
|
||||
role="group"
|
||||
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
|
||||
title={blocked ? blockedTitle : undefined}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{ALERT_MODES.map((option) => {
|
||||
const label = labels[option.mode];
|
||||
const disabled = Boolean(blocked);
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
className={`timer-alert-mode-button ${!blocked && mode === option.mode ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onChange(option.mode)}
|
||||
disabled={disabled}
|
||||
aria-pressed={mode === option.mode}
|
||||
aria-label={disabled ? blockedTitle : label}
|
||||
title={disabled ? blockedTitle : label}
|
||||
>
|
||||
<Icon name={option.icon} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimePartsInput({ value, onChange, allowWildcard = false }) {
|
||||
function updatePart(part, nextValue) {
|
||||
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
|
||||
onChange({ ...value, [part]: cleanValue });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
|
||||
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||
<span>:</span>
|
||||
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||
<span>:</span>
|
||||
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
|
||||
const inlineEdit = useInlineEdit({
|
||||
value,
|
||||
onCommit: onRename,
|
||||
onDone,
|
||||
autoFocus: true,
|
||||
commitUnchanged: true
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
|
||||
<input
|
||||
{...inlineEdit.getInputProps({
|
||||
className: "tool-split-entry-label timer-lap-label",
|
||||
placeholder,
|
||||
"aria-label": ariaLabel
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
website/src/features/toolboxes/modules/TimerStopwatch.jsx
Normal file
67
website/src/features/toolboxes/modules/TimerStopwatch.jsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Rôle : rend les contrôles et la liste d'étapes du chronomètre timer.
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { handleEditableKeyDown } from "./timerOptions.js";
|
||||
import { EditableTimerLabel, TimerValue } from "./TimerSharedControls.jsx";
|
||||
|
||||
export function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
|
||||
return (
|
||||
<div className="timer-control-panel" role="tabpanel">
|
||||
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
|
||||
<strong><TimerValue ms={elapsedMs} compact /></strong>
|
||||
<div className="timer-actions">
|
||||
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
|
||||
<Icon name={running ? "pause-circle" : "play-circle"} />
|
||||
</button>
|
||||
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
|
||||
<Icon name="record-circle" />
|
||||
</button>
|
||||
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
|
||||
<Icon name="stop-circle" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
|
||||
const [editingId, setEditingId] = useState("");
|
||||
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
|
||||
return (
|
||||
<ul className="tool-split-entry-list timer-list">
|
||||
{laps.map((lap, index) => (
|
||||
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
|
||||
<div className="tool-split-entry timer-entry has-single-action">
|
||||
{editingId === lap.id ? (
|
||||
<EditableTimerLabel
|
||||
value={lap.label}
|
||||
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
|
||||
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
|
||||
onRename={(label) => onRename(lap.id, label)}
|
||||
onDone={() => setEditingId("")}
|
||||
>
|
||||
<TimerValue ms={lap.elapsedMs} />
|
||||
</EditableTimerLabel>
|
||||
) : (
|
||||
<span
|
||||
className="tool-split-entry-summary timer-entry-summary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setEditingId(lap.id)}
|
||||
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
|
||||
>
|
||||
<span className="timer-entry-line">
|
||||
<TimerValue ms={lap.elapsedMs} />
|
||||
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
295
website/src/features/toolboxes/modules/ToolboxModules.jsx
Normal file
295
website/src/features/toolboxes/modules/ToolboxModules.jsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// Rôle : rend les modules d'une toolbox, leur shell commun et leur réorganisation.
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { supportsTextExchange } from "./textImport.js";
|
||||
import { LAZY_MODULE_TYPES, MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
|
||||
|
||||
const MODULE_COLUMN_IDS = ["left", "right"];
|
||||
|
||||
function getModuleMap(modules) {
|
||||
return new Map(modules.map((module) => [module.id, module]));
|
||||
}
|
||||
|
||||
function getModulesByIds(modules, ids) {
|
||||
const moduleMap = getModuleMap(modules);
|
||||
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
function getModuleColumnId(moduleId, twoColumnOrder) {
|
||||
return twoColumnOrder?.[1]?.includes(moduleId) ? MODULE_COLUMN_IDS[1] : MODULE_COLUMN_IDS[0];
|
||||
}
|
||||
|
||||
function moveId(ids, sourceId, targetId, placement = "before") {
|
||||
const nextIds = ids.filter((id) => id !== sourceId);
|
||||
const targetIndex = nextIds.indexOf(targetId);
|
||||
if (targetIndex < 0) return ids;
|
||||
nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceId);
|
||||
return nextIds;
|
||||
}
|
||||
|
||||
function moveModuleInOneColumnOrder(order, operation) {
|
||||
if (!operation.sourceId || !operation.targetId || operation.sourceId === operation.targetId) return order;
|
||||
return {
|
||||
...order,
|
||||
one: moveId(order.one, operation.sourceId, operation.targetId, operation.placement)
|
||||
};
|
||||
}
|
||||
|
||||
function moveModuleInTwoColumnOrder(order, operation) {
|
||||
const columns = [
|
||||
[...(order.two?.[0] || [])],
|
||||
[...(order.two?.[1] || [])]
|
||||
];
|
||||
const sourceColumnIndex = columns.findIndex((column) => column.includes(operation.sourceId));
|
||||
if (sourceColumnIndex < 0) return order;
|
||||
const targetColumnIndex = operation.targetType === "item"
|
||||
? columns.findIndex((column) => column.includes(operation.targetId))
|
||||
: MODULE_COLUMN_IDS.indexOf(operation.targetGroup);
|
||||
if (targetColumnIndex < 0) return order;
|
||||
|
||||
const [movedId] = columns[sourceColumnIndex].splice(columns[sourceColumnIndex].indexOf(operation.sourceId), 1);
|
||||
if (operation.targetType === "item") {
|
||||
const targetIndex = columns[targetColumnIndex].indexOf(operation.targetId);
|
||||
if (targetIndex < 0) return order;
|
||||
columns[targetColumnIndex].splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, movedId);
|
||||
} else {
|
||||
columns[targetColumnIndex].splice(operation.placement === "after" ? columns[targetColumnIndex].length : 0, 0, movedId);
|
||||
}
|
||||
return { ...order, two: columns };
|
||||
}
|
||||
|
||||
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
|
||||
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
|
||||
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
|
||||
const columns = useMemo(() => [
|
||||
getModulesByIds(toolbox.modules, twoColumnOrder[0]),
|
||||
getModulesByIds(toolbox.modules, twoColumnOrder[1])
|
||||
], [toolbox.modules, twoColumnOrder]);
|
||||
const {
|
||||
itemReorder,
|
||||
getItemProps,
|
||||
getGroupBoundaryProps,
|
||||
isItemDragging,
|
||||
isItemDropTarget,
|
||||
isBoundaryDropTarget,
|
||||
getDropPlacement
|
||||
} = useGroupedReorder({
|
||||
namespace: "toolbox-modules",
|
||||
items: toolbox.modules,
|
||||
getItemId: (module) => module.id,
|
||||
getItemGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
|
||||
getEffectiveGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
|
||||
getParentId: () => toolbox.id,
|
||||
reorderFeatures: {
|
||||
item: {
|
||||
groupChange: moduleColumns === 2,
|
||||
boundaryDrop: moduleColumns === 2
|
||||
}
|
||||
},
|
||||
onItemMove: (operation) => {
|
||||
const nextOrder = moduleColumns === 2
|
||||
? moveModuleInTwoColumnOrder(toolbox.moduleOrder, operation)
|
||||
: moveModuleInOneColumnOrder(toolbox.moduleOrder, operation);
|
||||
onModuleOrderChange(nextOrder);
|
||||
},
|
||||
hierarchy: { enabled: true, stickyParents: true }
|
||||
});
|
||||
|
||||
if (moduleColumns === 1) {
|
||||
return (
|
||||
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
|
||||
{orderedModules.map((module) => (
|
||||
<ModuleShell
|
||||
key={module.id}
|
||||
toolbox={toolbox}
|
||||
module={module}
|
||||
context={context}
|
||||
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
|
||||
itemGroup=""
|
||||
onRename={onRename}
|
||||
onUpdateModule={onUpdateModule}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0);
|
||||
|
||||
return (
|
||||
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
|
||||
{columns.map((modules, index) => (
|
||||
<div className={`module-column ${hasEmptyColumn && !modules.length ? "is-empty-warning" : ""}`} key={MODULE_COLUMN_IDS[index]}>
|
||||
<div
|
||||
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
|
||||
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" })}
|
||||
/>
|
||||
{modules.map((module) => (
|
||||
<ModuleShell
|
||||
key={module.id}
|
||||
toolbox={toolbox}
|
||||
module={module}
|
||||
context={context}
|
||||
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
|
||||
itemGroup={MODULE_COLUMN_IDS[index]}
|
||||
onRename={onRename}
|
||||
onUpdateModule={onUpdateModule}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
|
||||
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) {
|
||||
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
|
||||
const Component = definition.Component;
|
||||
const label = definition.label || module.type;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
|
||||
const hasAddPanel = definition.editable && definition.addable !== false;
|
||||
const hasTextExchange = supportsTextExchange(module.type);
|
||||
const scrollable = definition.scrollable && module.scrollable === true;
|
||||
const className = [
|
||||
"module",
|
||||
reorder.isItemDragging(module.id) ? "is-dragging" : "",
|
||||
reorder.isItemDropTarget(module.id) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
|
||||
scrollable ? "is-scrollable" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
const componentProps = {
|
||||
toolboxId: toolbox.id,
|
||||
moduleId: module.id,
|
||||
context,
|
||||
editing,
|
||||
setEditing,
|
||||
openEditor: () => {
|
||||
setTextExchangeOpen(false);
|
||||
setEditing(true);
|
||||
},
|
||||
textExchangeOpen
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className={className}
|
||||
data-module-type={module.type}
|
||||
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<button
|
||||
className="module-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, module.id)}
|
||||
aria-label={`Déplacer ${module.title || label}`}
|
||||
title="Déplacer"
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className={`module-icon-svg module-icon-${definition.icon || "notepad"}`} />
|
||||
</span>
|
||||
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "notepad" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
|
||||
</div>
|
||||
<div>
|
||||
{definition.scrollable && (
|
||||
<button
|
||||
className={`module-scroll-button ${scrollable ? "active" : ""}`}
|
||||
onClick={() => onUpdateModule(module.id, (currentModule) => {
|
||||
const nextModule = { ...currentModule };
|
||||
if (scrollable) {
|
||||
delete nextModule.scrollable;
|
||||
} else {
|
||||
nextModule.scrollable = true;
|
||||
}
|
||||
return nextModule;
|
||||
})}
|
||||
aria-label={`${scrollable ? "Désactiver" : "Activer"} le scroll de ${module.title || label}`}
|
||||
aria-pressed={scrollable}
|
||||
title={scrollable ? "Désactiver le scroll" : "Activer le scroll"}
|
||||
>
|
||||
<Icon name="scrollable" />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{hasAddPanel && (
|
||||
<button
|
||||
className={`module-edit-button ${editing ? "active" : ""}`}
|
||||
onClick={() => setEditing((value) => {
|
||||
const nextEditing = !value;
|
||||
if (nextEditing) setTextExchangeOpen(false);
|
||||
return nextEditing;
|
||||
})}
|
||||
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
|
||||
aria-pressed={editing}
|
||||
title={editing ? "Masquer l'ajout" : "Ajouter"}
|
||||
>
|
||||
<Icon name="add" />
|
||||
</button>
|
||||
)}
|
||||
{hasTextExchange && (
|
||||
<button
|
||||
className={`module-exchange-button ${textExchangeOpen ? "active" : ""}`}
|
||||
onClick={() => setTextExchangeOpen((value) => {
|
||||
const nextOpen = !value;
|
||||
if (nextOpen) setEditing(false);
|
||||
return nextOpen;
|
||||
})}
|
||||
aria-label={`${textExchangeOpen ? "Masquer" : "Afficher"} l'import/export de ${module.title || label}`}
|
||||
aria-pressed={textExchangeOpen}
|
||||
title={textExchangeOpen ? "Masquer import/export" : "Import / export"}
|
||||
>
|
||||
<Icon name="import" />
|
||||
</button>
|
||||
)}
|
||||
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
|
||||
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
|
||||
{LAZY_MODULE_TYPES.has(module.type) ? (
|
||||
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
|
||||
<Component {...componentProps} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<Component {...componentProps} />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleLoadingFallback({ label }) {
|
||||
return <div className="module-loading-fallback">Chargement de {label}...</div>;
|
||||
}
|
||||
|
||||
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
|
||||
const inlineEdit = useInlineEdit({
|
||||
value,
|
||||
onCommit: (title) => onSave(title || fallback),
|
||||
blurOnEscape: true
|
||||
});
|
||||
|
||||
return (
|
||||
<h2
|
||||
{...inlineEdit.getContentEditableProps({
|
||||
className: "module-title",
|
||||
"data-toolbox-tutorial-target": tutorialTarget || undefined,
|
||||
title: "Cliquer pour renommer"
|
||||
})}
|
||||
>
|
||||
{value}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
110
website/src/features/toolboxes/modules/calendarEventActions.js
Normal file
110
website/src/features/toolboxes/modules/calendarEventActions.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// Rôle : construit les événements calendrier depuis les drafts, occurrences et actions de collage.
|
||||
import {
|
||||
CALENDAR_ALERT_MODES,
|
||||
CALENDAR_COLORS,
|
||||
WEEK_SLOT_MINUTES,
|
||||
addDays,
|
||||
formatLocalDateKey,
|
||||
getLocalDayIndex,
|
||||
minutesToTimeValue,
|
||||
parseLocalDateKey,
|
||||
timeValueToMinutes
|
||||
} from "./calendarUtils.js";
|
||||
import {
|
||||
getCalendarRepeatMode,
|
||||
getDateSpanDays,
|
||||
normalizeDraftAlertOffsets,
|
||||
normalizeDraftDateKey,
|
||||
normalizeDraftMonthlyRepeatBy
|
||||
} from "./calendarDraft.js";
|
||||
|
||||
export function buildPastedCalendarEvent({ copiedEvent, target, data, draft, context }) {
|
||||
if (!copiedEvent) return null;
|
||||
const startMinutes = data.viewMode === "month" ? 0 : target.slotIndex >= 0 ? target.slotIndex * WEEK_SLOT_MINUTES : timeValueToMinutes(draft.startTime);
|
||||
const durationMinutes = copiedEvent.endMinutes && copiedEvent.endMinutes > copiedEvent.startMinutes ? copiedEvent.endMinutes - copiedEvent.startMinutes : 0;
|
||||
const nextEvent = {
|
||||
...copiedEvent,
|
||||
id: context.uid("calendar"),
|
||||
startMinutes,
|
||||
repeatable: copiedEvent.repeatable !== false,
|
||||
repeat: copiedEvent.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode)
|
||||
};
|
||||
if (data.viewMode === "month") {
|
||||
const copiedDaySpan = getDateSpanDays(copiedEvent.startDate, copiedEvent.endDate || copiedEvent.startDate);
|
||||
nextEvent.startDate = target.startDate;
|
||||
nextEvent.endDate = formatLocalDateKey(addDays(parseLocalDateKey(target.startDate), copiedDaySpan));
|
||||
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(target.startDate));
|
||||
nextEvent.monthlyRepeatBy = target.monthlyRepeatBy || copiedEvent.monthlyRepeatBy || "monthDay";
|
||||
nextEvent.repeat = copiedEvent.repeat || "monthly";
|
||||
nextEvent.excludedOccurrences = [];
|
||||
delete nextEvent.monthDay;
|
||||
} else {
|
||||
nextEvent.dayIndex = target.dayIndex;
|
||||
delete nextEvent.monthDay;
|
||||
delete nextEvent.monthlyRepeatBy;
|
||||
}
|
||||
delete nextEvent.endMinutes;
|
||||
if (data.viewMode === "week" && durationMinutes > 0) nextEvent.endMinutes = Math.min(23 * 60 + 59, startMinutes + durationMinutes);
|
||||
return nextEvent;
|
||||
}
|
||||
|
||||
export function getCalendarDraftFromEvent({ event, occurrence, now }) {
|
||||
const occurrenceDate = occurrence ? formatLocalDateKey(new Date(occurrence.startMs)) : "";
|
||||
return {
|
||||
occurrenceDate,
|
||||
draft: {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
dayIndex: event.dayIndex,
|
||||
startDate: occurrenceDate || event.startDate || formatLocalDateKey(now),
|
||||
endDate: occurrenceDate && event.startDate ? formatLocalDateKey(addDays(parseLocalDateKey(occurrenceDate), getDateSpanDays(event.startDate, event.endDate || event.startDate))) : event.endDate || event.startDate || formatLocalDateKey(now),
|
||||
monthlyRepeatBy: event.monthlyRepeatBy || "monthDay",
|
||||
repeat: event.repeat || "none",
|
||||
startTime: minutesToTimeValue(event.startMinutes),
|
||||
endTime: event.endMinutes ? minutesToTimeValue(event.endMinutes) : "",
|
||||
repeatable: event.repeatable !== false,
|
||||
alertMode: event.alertMode || "off",
|
||||
alertOffsetMinutes: event.alertMode === "off" ? [] : normalizeDraftAlertOffsets(event.alertOffsetMinutes),
|
||||
color: event.color || "violet"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCalendarEventFromDraft({ draft, data, context }) {
|
||||
const title = draft.title.trim();
|
||||
const dayIndex = Number(draft.dayIndex);
|
||||
const startDate = normalizeDraftDateKey(draft.startDate);
|
||||
const endDate = normalizeDraftDateKey(draft.endDate || draft.startDate);
|
||||
const startMinutes = data.viewMode === "month" ? 0 : timeValueToMinutes(draft.startTime);
|
||||
const endMinutes = data.viewMode === "month" ? -1 : draft.endTime ? timeValueToMinutes(draft.endTime) : -1;
|
||||
const monthlyRepeatBy = normalizeDraftMonthlyRepeatBy(draft.monthlyRepeatBy);
|
||||
const monthRepeat = monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : ["none", "weekly", "monthly"].includes(draft.repeat) ? draft.repeat : "monthly";
|
||||
const validWeekTarget = data.viewMode === "week" && dayIndex >= 0 && dayIndex <= 6;
|
||||
const validMonthTarget = data.viewMode === "month" && startDate && endDate && endDate >= startDate;
|
||||
if (!title || (!validWeekTarget && !validMonthTarget) || startMinutes < 0) return { error: "required" };
|
||||
if (draft.endTime && data.viewMode === "week" && endMinutes <= startMinutes) return { error: "range" };
|
||||
|
||||
const alertOffsets = normalizeDraftAlertOffsets(draft.alertOffsetMinutes);
|
||||
const alertMode = alertOffsets.length && CALENDAR_ALERT_MODES.includes(draft.alertMode) ? draft.alertMode : "off";
|
||||
const nextEvent = {
|
||||
id: draft.id || context.uid("calendar"),
|
||||
title,
|
||||
startMinutes,
|
||||
...(data.viewMode === "week" && draft.endTime ? { endMinutes } : {}),
|
||||
repeatable: draft.repeatable !== false,
|
||||
repeat: data.viewMode === "month" ? monthRepeat : draft.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode),
|
||||
alertMode,
|
||||
alertOffsetMinutes: alertOffsets.length ? alertOffsets : [10],
|
||||
color: CALENDAR_COLORS.includes(draft.color) ? draft.color : "violet"
|
||||
};
|
||||
if (data.viewMode === "month") {
|
||||
nextEvent.monthlyRepeatBy = monthlyRepeatBy;
|
||||
nextEvent.startDate = startDate;
|
||||
nextEvent.endDate = endDate;
|
||||
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(startDate));
|
||||
nextEvent.excludedOccurrences = data.events.find((item) => item.id === draft.id)?.excludedOccurrences || [];
|
||||
} else {
|
||||
nextEvent.dayIndex = dayIndex;
|
||||
}
|
||||
return { event: nextEvent };
|
||||
}
|
||||
|
|
@ -1,432 +1,5 @@
|
|||
// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation.
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
|
||||
import { TOOLBOX_MODULE_DEFINITIONS, TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
|
||||
import { supportsTextExchange } from "./textImport.js";
|
||||
import { CalculatorModule } from "./CalculatorModule.jsx";
|
||||
import { ChecklistModule } from "./ChecklistModule.jsx";
|
||||
import { CountersModule } from "./CountersModule.jsx";
|
||||
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
|
||||
import { LinksModule } from "./LinksModule.jsx";
|
||||
import { NotepadModule } from "./NotepadModule.jsx";
|
||||
import { ImagesModule } from "./ImagesModule.jsx";
|
||||
import { TableModule } from "./TableModule.jsx";
|
||||
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
|
||||
import { TimerModule } from "./TimerModule.jsx";
|
||||
|
||||
const CombosModule = lazy(() => import("./CombosModule.jsx").then((module) => ({ default: module.CombosModule })));
|
||||
const CalendarModule = lazy(() => import("./CalendarModule.jsx").then((module) => ({ default: module.CalendarModule })));
|
||||
const GoalTreeModule = lazy(() => import("./GoalTreeModule.jsx").then((module) => ({ default: module.GoalTreeModule })));
|
||||
const EquipmentPlannerModule = lazy(() => import("./EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule })));
|
||||
|
||||
const MODULE_COMPONENTS = {
|
||||
notepad: { ...TOOLBOX_MODULE_DEFINITIONS.notepad, Component: NotepadModule },
|
||||
checklist: { ...TOOLBOX_MODULE_DEFINITIONS.checklist, Component: ChecklistModule },
|
||||
images: { ...TOOLBOX_MODULE_DEFINITIONS.images, Component: ImagesModule },
|
||||
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
|
||||
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
|
||||
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
|
||||
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
|
||||
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
|
||||
table: { ...TOOLBOX_MODULE_DEFINITIONS.table, Component: TableModule },
|
||||
goalTree: { ...TOOLBOX_MODULE_DEFINITIONS.goalTree, Component: GoalTreeModule },
|
||||
timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
|
||||
taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
|
||||
equipmentPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.equipmentPlanner, Component: EquipmentPlannerModule },
|
||||
imageAnnotation: { ...TOOLBOX_MODULE_DEFINITIONS.imageAnnotation, Component: ImageAnnotationModule }
|
||||
};
|
||||
|
||||
const LAZY_MODULE_TYPES = new Set(["combos", "calendar", "goalTree", "equipmentPlanner"]);
|
||||
|
||||
const MODULE_COLUMN_IDS = ["left", "right"];
|
||||
|
||||
export { TOOLBOX_MODULES };
|
||||
|
||||
function getModuleMap(modules) {
|
||||
return new Map(modules.map((module) => [module.id, module]));
|
||||
}
|
||||
|
||||
function getModulesByIds(modules, ids) {
|
||||
const moduleMap = getModuleMap(modules);
|
||||
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
function getModuleColumnId(moduleId, twoColumnOrder) {
|
||||
return twoColumnOrder?.[1]?.includes(moduleId) ? MODULE_COLUMN_IDS[1] : MODULE_COLUMN_IDS[0];
|
||||
}
|
||||
|
||||
function moveId(ids, sourceId, targetId, placement = "before") {
|
||||
const nextIds = ids.filter((id) => id !== sourceId);
|
||||
const targetIndex = nextIds.indexOf(targetId);
|
||||
if (targetIndex < 0) return ids;
|
||||
nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceId);
|
||||
return nextIds;
|
||||
}
|
||||
|
||||
function moveModuleInOneColumnOrder(order, operation) {
|
||||
if (!operation.sourceId || !operation.targetId || operation.sourceId === operation.targetId) return order;
|
||||
return {
|
||||
...order,
|
||||
one: moveId(order.one, operation.sourceId, operation.targetId, operation.placement)
|
||||
};
|
||||
}
|
||||
|
||||
function moveModuleInTwoColumnOrder(order, operation) {
|
||||
const columns = [
|
||||
[...(order.two?.[0] || [])],
|
||||
[...(order.two?.[1] || [])]
|
||||
];
|
||||
const sourceColumnIndex = columns.findIndex((column) => column.includes(operation.sourceId));
|
||||
if (sourceColumnIndex < 0) return order;
|
||||
const targetColumnIndex = operation.targetType === "item"
|
||||
? columns.findIndex((column) => column.includes(operation.targetId))
|
||||
: MODULE_COLUMN_IDS.indexOf(operation.targetGroup);
|
||||
if (targetColumnIndex < 0) return order;
|
||||
|
||||
const [movedId] = columns[sourceColumnIndex].splice(columns[sourceColumnIndex].indexOf(operation.sourceId), 1);
|
||||
if (operation.targetType === "item") {
|
||||
const targetIndex = columns[targetColumnIndex].indexOf(operation.targetId);
|
||||
if (targetIndex < 0) return order;
|
||||
columns[targetColumnIndex].splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, movedId);
|
||||
} else {
|
||||
columns[targetColumnIndex].splice(operation.placement === "after" ? columns[targetColumnIndex].length : 0, 0, movedId);
|
||||
}
|
||||
return { ...order, two: columns };
|
||||
}
|
||||
|
||||
export function AddToolControls({ onAdd }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const modules = Object.entries(TOOLBOX_MODULES);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
return lockBodyScroll();
|
||||
}, [open]);
|
||||
|
||||
function addTool(type) {
|
||||
onAdd(type);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="tool-add-controls"
|
||||
aria-label="Ajouter un outil"
|
||||
>
|
||||
<button
|
||||
className="primary tool-add-modal-button"
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
data-toolbox-tutorial-target="addTool"
|
||||
>
|
||||
Ajouter un outil
|
||||
</button>
|
||||
<CompactDropdown
|
||||
className="tool-add-quick-dropdown"
|
||||
menuClassName="tool-quick-add"
|
||||
label="Ajout rapide"
|
||||
openOnHover
|
||||
closeOnMouseLeave
|
||||
renderTrigger={({ open: quickOpen, toggle }) => (
|
||||
<button
|
||||
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label="Afficher l'ajout rapide"
|
||||
aria-expanded={quickOpen}
|
||||
title="Ajout rapide"
|
||||
>
|
||||
<Icon name="dropdown" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{modules.map(([type, module]) => (
|
||||
<button
|
||||
key={type}
|
||||
className="tool-quick-add-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAdd(type);
|
||||
close();
|
||||
}}
|
||||
aria-label={`Ajouter ${module.label}`}
|
||||
title={module.label}
|
||||
>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className={`module-icon-svg module-icon-${module.icon}`} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CompactDropdown>
|
||||
</div>
|
||||
{open && createPortal(
|
||||
<div className="tool-add-modal-root" role="dialog" aria-modal="true" aria-labelledby="tool-add-modal-title">
|
||||
<div className="confirm-backdrop" onClick={() => setOpen(false)} />
|
||||
<section className="confirm-modal tool-add-modal">
|
||||
<header>
|
||||
<h2 id="tool-add-modal-title">Ajouter un outil</h2>
|
||||
<button className="drawer-close-button" type="button" onClick={() => setOpen(false)} aria-label="Fermer" title="Fermer">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="tool-add-grid">
|
||||
{modules.map(([type, module]) => (
|
||||
<button key={type} className="tool-add-card" type="button" onClick={() => addTool(type)}>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className={`module-icon-svg module-icon-${module.icon}`} />
|
||||
</span>
|
||||
<span>{module.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
|
||||
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
|
||||
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
|
||||
const columns = useMemo(() => [
|
||||
getModulesByIds(toolbox.modules, twoColumnOrder[0]),
|
||||
getModulesByIds(toolbox.modules, twoColumnOrder[1])
|
||||
], [toolbox.modules, twoColumnOrder]);
|
||||
const {
|
||||
itemReorder,
|
||||
getItemProps,
|
||||
getGroupBoundaryProps,
|
||||
isItemDragging,
|
||||
isItemDropTarget,
|
||||
isBoundaryDropTarget,
|
||||
getDropPlacement
|
||||
} = useGroupedReorder({
|
||||
namespace: "toolbox-modules",
|
||||
items: toolbox.modules,
|
||||
getItemId: (module) => module.id,
|
||||
getItemGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
|
||||
getEffectiveGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
|
||||
getParentId: () => toolbox.id,
|
||||
reorderFeatures: {
|
||||
item: {
|
||||
groupChange: moduleColumns === 2,
|
||||
boundaryDrop: moduleColumns === 2
|
||||
}
|
||||
},
|
||||
onItemMove: (operation) => {
|
||||
const nextOrder = moduleColumns === 2
|
||||
? moveModuleInTwoColumnOrder(toolbox.moduleOrder, operation)
|
||||
: moveModuleInOneColumnOrder(toolbox.moduleOrder, operation);
|
||||
onModuleOrderChange(nextOrder);
|
||||
},
|
||||
hierarchy: { enabled: true, stickyParents: true }
|
||||
});
|
||||
|
||||
if (moduleColumns === 1) {
|
||||
return (
|
||||
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
|
||||
{orderedModules.map((module) => (
|
||||
<ModuleShell
|
||||
key={module.id}
|
||||
toolbox={toolbox}
|
||||
module={module}
|
||||
context={context}
|
||||
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
|
||||
itemGroup=""
|
||||
onRename={onRename}
|
||||
onUpdateModule={onUpdateModule}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0);
|
||||
|
||||
return (
|
||||
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
|
||||
{columns.map((modules, index) => (
|
||||
<div className={`module-column ${hasEmptyColumn && !modules.length ? "is-empty-warning" : ""}`} key={MODULE_COLUMN_IDS[index]}>
|
||||
<div
|
||||
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
|
||||
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" })}
|
||||
/>
|
||||
{modules.map((module) => (
|
||||
<ModuleShell
|
||||
key={module.id}
|
||||
toolbox={toolbox}
|
||||
module={module}
|
||||
context={context}
|
||||
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
|
||||
itemGroup={MODULE_COLUMN_IDS[index]}
|
||||
onRename={onRename}
|
||||
onUpdateModule={onUpdateModule}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
|
||||
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) {
|
||||
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
|
||||
const Component = definition.Component;
|
||||
const label = definition.label || module.type;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
|
||||
const hasAddPanel = definition.editable && definition.addable !== false;
|
||||
const hasTextExchange = supportsTextExchange(module.type);
|
||||
const scrollable = definition.scrollable && module.scrollable === true;
|
||||
const className = [
|
||||
"module",
|
||||
reorder.isItemDragging(module.id) ? "is-dragging" : "",
|
||||
reorder.isItemDropTarget(module.id) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
|
||||
scrollable ? "is-scrollable" : ""
|
||||
].filter(Boolean).join(" ");
|
||||
const componentProps = {
|
||||
toolboxId: toolbox.id,
|
||||
moduleId: module.id,
|
||||
context,
|
||||
editing,
|
||||
setEditing,
|
||||
openEditor: () => {
|
||||
setTextExchangeOpen(false);
|
||||
setEditing(true);
|
||||
},
|
||||
textExchangeOpen
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className={className}
|
||||
data-module-type={module.type}
|
||||
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<button
|
||||
className="module-drag-handle"
|
||||
type="button"
|
||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, module.id)}
|
||||
aria-label={`Déplacer ${module.title || label}`}
|
||||
title="Déplacer"
|
||||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className={`module-icon-svg module-icon-${definition.icon || "notepad"}`} />
|
||||
</span>
|
||||
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "notepad" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
|
||||
</div>
|
||||
<div>
|
||||
{definition.scrollable && (
|
||||
<button
|
||||
className={`module-scroll-button ${scrollable ? "active" : ""}`}
|
||||
onClick={() => onUpdateModule(module.id, (currentModule) => {
|
||||
const nextModule = { ...currentModule };
|
||||
if (scrollable) {
|
||||
delete nextModule.scrollable;
|
||||
} else {
|
||||
nextModule.scrollable = true;
|
||||
}
|
||||
return nextModule;
|
||||
})}
|
||||
aria-label={`${scrollable ? "Désactiver" : "Activer"} le scroll de ${module.title || label}`}
|
||||
aria-pressed={scrollable}
|
||||
title={scrollable ? "Désactiver le scroll" : "Activer le scroll"}
|
||||
>
|
||||
<Icon name="scrollable" />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{hasAddPanel && (
|
||||
<button
|
||||
className={`module-edit-button ${editing ? "active" : ""}`}
|
||||
onClick={() => setEditing((value) => {
|
||||
const nextEditing = !value;
|
||||
if (nextEditing) setTextExchangeOpen(false);
|
||||
return nextEditing;
|
||||
})}
|
||||
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
|
||||
aria-pressed={editing}
|
||||
title={editing ? "Masquer l'ajout" : "Ajouter"}
|
||||
>
|
||||
<Icon name="add" />
|
||||
</button>
|
||||
)}
|
||||
{hasTextExchange && (
|
||||
<button
|
||||
className={`module-exchange-button ${textExchangeOpen ? "active" : ""}`}
|
||||
onClick={() => setTextExchangeOpen((value) => {
|
||||
const nextOpen = !value;
|
||||
if (nextOpen) setEditing(false);
|
||||
return nextOpen;
|
||||
})}
|
||||
aria-label={`${textExchangeOpen ? "Masquer" : "Afficher"} l'import/export de ${module.title || label}`}
|
||||
aria-pressed={textExchangeOpen}
|
||||
title={textExchangeOpen ? "Masquer import/export" : "Import / export"}
|
||||
>
|
||||
<Icon name="import" />
|
||||
</button>
|
||||
)}
|
||||
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
|
||||
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
|
||||
{LAZY_MODULE_TYPES.has(module.type) ? (
|
||||
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
|
||||
<Component {...componentProps} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<Component {...componentProps} />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleLoadingFallback({ label }) {
|
||||
return <div className="module-loading-fallback">Chargement de {label}...</div>;
|
||||
}
|
||||
|
||||
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
|
||||
const inlineEdit = useInlineEdit({
|
||||
value,
|
||||
onCommit: (title) => onSave(title || fallback),
|
||||
blurOnEscape: true
|
||||
});
|
||||
|
||||
return (
|
||||
<h2
|
||||
{...inlineEdit.getContentEditableProps({
|
||||
className: "module-title",
|
||||
"data-toolbox-tutorial-target": tutorialTarget || undefined,
|
||||
title: "Cliquer pour renommer"
|
||||
})}
|
||||
>
|
||||
{value}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
// Rôle : expose l'API publique du registre et du rendu des modules toolbox.
|
||||
export { TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
|
||||
export { AddToolControls } from "./AddToolControls.jsx";
|
||||
export { ToolboxModules } from "./ToolboxModules.jsx";
|
||||
export { LAZY_MODULE_TYPES, MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
// Rôle : associe les types d'outils toolbox à leurs composants React, synchrones ou lazy.
|
||||
/* eslint-disable react-refresh/only-export-components -- registre de composants toolbox consommé par le shell. */
|
||||
import { lazy } from "react";
|
||||
import { TOOLBOX_MODULE_DEFINITIONS } from "../toolboxModuleRegistry.js";
|
||||
import { CalculatorModule } from "./CalculatorModule.jsx";
|
||||
import { ChecklistModule } from "./ChecklistModule.jsx";
|
||||
import { CountersModule } from "./CountersModule.jsx";
|
||||
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
|
||||
import { ImagesModule } from "./ImagesModule.jsx";
|
||||
import { LinksModule } from "./LinksModule.jsx";
|
||||
import { NotepadModule } from "./NotepadModule.jsx";
|
||||
import { TableModule } from "./TableModule.jsx";
|
||||
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
|
||||
import { TimerModule } from "./TimerModule.jsx";
|
||||
|
||||
const CombosModule = lazy(() => import("./CombosModule.jsx").then((module) => ({ default: module.CombosModule })));
|
||||
const CalendarModule = lazy(() => import("./CalendarModule.jsx").then((module) => ({ default: module.CalendarModule })));
|
||||
const GoalTreeModule = lazy(() => import("./GoalTreeModule.jsx").then((module) => ({ default: module.GoalTreeModule })));
|
||||
const EquipmentPlannerModule = lazy(() => import("./EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule })));
|
||||
|
||||
export const MODULE_COMPONENTS = {
|
||||
notepad: { ...TOOLBOX_MODULE_DEFINITIONS.notepad, Component: NotepadModule },
|
||||
checklist: { ...TOOLBOX_MODULE_DEFINITIONS.checklist, Component: ChecklistModule },
|
||||
images: { ...TOOLBOX_MODULE_DEFINITIONS.images, Component: ImagesModule },
|
||||
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
|
||||
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
|
||||
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
|
||||
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
|
||||
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
|
||||
table: { ...TOOLBOX_MODULE_DEFINITIONS.table, Component: TableModule },
|
||||
goalTree: { ...TOOLBOX_MODULE_DEFINITIONS.goalTree, Component: GoalTreeModule },
|
||||
timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
|
||||
taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
|
||||
equipmentPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.equipmentPlanner, Component: EquipmentPlannerModule },
|
||||
imageAnnotation: { ...TOOLBOX_MODULE_DEFINITIONS.imageAnnotation, Component: ImageAnnotationModule }
|
||||
};
|
||||
|
||||
export const LAZY_MODULE_TYPES = new Set(["combos", "calendar", "goalTree", "equipmentPlanner"]);
|
||||
282
website/src/features/toolboxes/modules/useCalendarController.js
Normal file
282
website/src/features/toolboxes/modules/useCalendarController.js
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
// Rôle : orchestre l'état, les handlers et les effets du module calendrier.
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
WEEK_SLOT_MINUTES,
|
||||
addDays,
|
||||
getCalendarOccurrences,
|
||||
getLocalDayIndex,
|
||||
getMonthEnd,
|
||||
getMonthGridDays,
|
||||
getMonthStart,
|
||||
getWeekDays,
|
||||
getWeekStart,
|
||||
minutesToTimeValue,
|
||||
parseLocalDateKey
|
||||
} from "./calendarUtils.js";
|
||||
import { WEEK_SLOT_COUNT, getWeekGridLayout } from "./calendarLayout.js";
|
||||
import {
|
||||
createDefaultDraft,
|
||||
getDraftSelection,
|
||||
normalizeDraftAlertOffsets
|
||||
} from "./calendarDraft.js";
|
||||
import {
|
||||
buildCalendarEventFromDraft,
|
||||
buildPastedCalendarEvent,
|
||||
getCalendarDraftFromEvent
|
||||
} from "./calendarEventActions.js";
|
||||
import {
|
||||
useCalendarClock,
|
||||
useCalendarEditingReset,
|
||||
usePruneExpiredCalendarEvents
|
||||
} from "./useCalendarLifecycle.js";
|
||||
import { useCalendarDraftControls } from "./useCalendarDraftControls.js";
|
||||
|
||||
export function useCalendarController({ data, context, toolboxId, moduleId, editing = false, setEditing, textContent }) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [displayedMonth, setDisplayedMonth] = useState(() => getMonthStart(new Date()));
|
||||
const weekStart = useMemo(() => getWeekStart(new Date(), data.weekStartsOn), [data.weekStartsOn]);
|
||||
const monthStart = useMemo(() => getMonthStart(displayedMonth), [displayedMonth]);
|
||||
const monthEnd = useMemo(() => getMonthEnd(monthStart), [monthStart]);
|
||||
const monthGridDays = useMemo(() => getMonthGridDays(monthStart), [monthStart]);
|
||||
const [selection, setSelection] = useState(null);
|
||||
const [monthSelection, setMonthSelection] = useState(null);
|
||||
const [draft, setDraft] = useState(() => createDefaultDraft());
|
||||
const [activeEventId, setActiveEventId] = useState("");
|
||||
const [activeOccurrenceDate, setActiveOccurrenceDate] = useState("");
|
||||
const [copiedEvent, setCopiedEvent] = useState(null);
|
||||
const [hideEmptyHours, setHideEmptyHours] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const previousEditingRef = useRef(editing);
|
||||
const days = useMemo(() => getWeekDays(weekStart), [weekStart]);
|
||||
const weekEnd = useMemo(() => addDays(weekStart, 7), [weekStart]);
|
||||
const rangeStart = data.viewMode === "month" ? monthStart : weekStart;
|
||||
const rangeEnd = data.viewMode === "month" ? monthEnd : weekEnd;
|
||||
const occurrences = useMemo(() => getCalendarOccurrences(data.events, rangeStart, rangeEnd), [data.events, rangeEnd, rangeStart]);
|
||||
const draftSelection = data.viewMode === "week" ? getDraftSelection(draft) : null;
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const nowDayIndex = getLocalDayIndex(now);
|
||||
const formOpen = editing || Boolean(activeEventId);
|
||||
const weekGridLayout = useMemo(() => {
|
||||
return getWeekGridLayout(occurrences, data.viewMode === "week" && hideEmptyHours && !formOpen);
|
||||
}, [data.viewMode, formOpen, hideEmptyHours, occurrences]);
|
||||
const { updateAlertMode, updateDraft, toggleAlertOffset } = useCalendarDraftControls({ setDraft, setError });
|
||||
|
||||
const save = useCallback((nextData) => {
|
||||
context.setModuleData(toolboxId, moduleId, nextData, "calendar");
|
||||
}, [context, moduleId, toolboxId]);
|
||||
|
||||
function initializeWeek() {
|
||||
save({ ...data, initialized: true, viewMode: "week", weekStartsOn: 1, events: [] });
|
||||
}
|
||||
|
||||
function initializeMonth() {
|
||||
save({ ...data, initialized: true, viewMode: "month", weekStartsOn: 1, events: [] });
|
||||
}
|
||||
|
||||
const resetDraft = useCallback(() => {
|
||||
setDraft(createDefaultDraft());
|
||||
setSelection(null);
|
||||
setMonthSelection(null);
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
function closeForm() {
|
||||
resetDraft();
|
||||
setEditing?.(false);
|
||||
}
|
||||
|
||||
function applySelection(dayIndex, startSlot, endSlot = startSlot) {
|
||||
const orderedStart = Math.min(startSlot, endSlot);
|
||||
const orderedEnd = Math.max(startSlot, endSlot);
|
||||
updateDraft({
|
||||
id: "",
|
||||
dayIndex,
|
||||
startTime: minutesToTimeValue(orderedStart * WEEK_SLOT_MINUTES),
|
||||
endTime: minutesToTimeValue(Math.min(23 * 60 + 59, (Math.min(WEEK_SLOT_COUNT, orderedEnd + 1)) * WEEK_SLOT_MINUTES))
|
||||
});
|
||||
}
|
||||
|
||||
function applyMonthSelection(startDate, endDate = startDate) {
|
||||
const orderedStart = startDate <= endDate ? startDate : endDate;
|
||||
const orderedEnd = startDate <= endDate ? endDate : startDate;
|
||||
const start = parseLocalDateKey(orderedStart);
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
updateDraft({
|
||||
id: "",
|
||||
startDate: orderedStart,
|
||||
endDate: orderedEnd,
|
||||
dayIndex: start ? getLocalDayIndex(start) : 0
|
||||
});
|
||||
}
|
||||
|
||||
function pasteEventAtTarget(target) {
|
||||
const nextEvent = buildPastedCalendarEvent({ copiedEvent, target, data, draft, context });
|
||||
if (!nextEvent) return;
|
||||
save({ ...data, initialized: true, events: [...data.events, nextEvent] });
|
||||
context.notify?.(textContent.pastedTitle || "Événement collé.");
|
||||
}
|
||||
|
||||
function pasteCopiedEventAtDraftSelection() {
|
||||
if (!copiedEvent) return;
|
||||
if (data.viewMode === "month") {
|
||||
if (!parseLocalDateKey(draft.startDate)) return;
|
||||
pasteEventAtTarget({
|
||||
startDate: draft.startDate,
|
||||
monthlyRepeatBy: draft.monthlyRepeatBy || "monthDay",
|
||||
slotIndex: -1
|
||||
});
|
||||
return;
|
||||
}
|
||||
const targetSelection = getDraftSelection(draft);
|
||||
if (targetSelection) pasteEventAtTarget({ dayIndex: targetSelection.dayIndex, slotIndex: targetSelection.startSlot });
|
||||
}
|
||||
|
||||
function handleSlotPointerDown(dayIndex, slotIndex) {
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
setSelection({ dayIndex, startSlot: slotIndex, endSlot: slotIndex });
|
||||
applySelection(dayIndex, slotIndex);
|
||||
}
|
||||
|
||||
function handleSlotPointerEnter(event, dayIndex, slotIndex) {
|
||||
if (event.buttons !== 1) return;
|
||||
setSelection((current) => {
|
||||
if (!current || current.dayIndex !== dayIndex) return current;
|
||||
applySelection(dayIndex, current.startSlot, slotIndex);
|
||||
return { ...current, endSlot: slotIndex };
|
||||
});
|
||||
}
|
||||
|
||||
function handleMonthPointerDown(dateKey) {
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
setMonthSelection({ startDate: dateKey, endDate: dateKey });
|
||||
applyMonthSelection(dateKey);
|
||||
}
|
||||
|
||||
function handleMonthPointerEnter(event, dateKey) {
|
||||
if (event.buttons !== 1) return;
|
||||
setMonthSelection((current) => {
|
||||
if (!current) return current;
|
||||
applyMonthSelection(current.startDate, dateKey);
|
||||
return { ...current, endDate: dateKey };
|
||||
});
|
||||
}
|
||||
|
||||
function editEvent(event, occurrence = null) {
|
||||
const { draft: nextDraft, occurrenceDate } = getCalendarDraftFromEvent({ event, occurrence, now });
|
||||
if (occurrenceDate) setDisplayedMonth(getMonthStart(parseLocalDateKey(occurrenceDate)));
|
||||
setDraft(nextDraft);
|
||||
setActiveEventId(event.id);
|
||||
setActiveOccurrenceDate(occurrenceDate);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function deleteEvent(eventId) {
|
||||
save({ ...data, initialized: true, events: data.events.filter((event) => event.id !== eventId) });
|
||||
if (draft.id === eventId || activeEventId === eventId) closeForm();
|
||||
}
|
||||
|
||||
function deleteOccurrence(eventId) {
|
||||
const event = data.events.find((item) => item.id === eventId);
|
||||
if (!event || !activeOccurrenceDate || event.repeat === "none") return;
|
||||
const events = data.events.map((item) => item.id === eventId
|
||||
? { ...item, excludedOccurrences: [...new Set([...(item.excludedOccurrences || []), activeOccurrenceDate])].sort() }
|
||||
: item);
|
||||
save({ ...data, initialized: true, events });
|
||||
closeForm();
|
||||
}
|
||||
|
||||
function copyEvent(event) {
|
||||
setCopiedEvent(event);
|
||||
context.notify?.(textContent.copiedTitle || "Événement copié.");
|
||||
}
|
||||
|
||||
function submitEvent(event) {
|
||||
event.preventDefault();
|
||||
const result = buildCalendarEventFromDraft({ draft, data, context });
|
||||
if (result.error === "required") {
|
||||
setError(textContent.requiredError || "Titre, date et heure de début requis.");
|
||||
return;
|
||||
}
|
||||
if (result.error === "range") {
|
||||
setError(textContent.invalidRangeError || "La fin doit être après le début.");
|
||||
return;
|
||||
}
|
||||
const events = draft.id
|
||||
? data.events.map((item) => item.id === draft.id ? result.event : item)
|
||||
: [...data.events, result.event];
|
||||
save({ ...data, initialized: true, events });
|
||||
closeForm();
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
const target = event.target;
|
||||
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "c") {
|
||||
const activeEvent = data.events.find((item) => item.id === activeEventId);
|
||||
if (!activeEvent) return;
|
||||
event.preventDefault();
|
||||
copyEvent(activeEvent);
|
||||
}
|
||||
if (key === "v") {
|
||||
if (!copiedEvent) return;
|
||||
event.preventDefault();
|
||||
pasteCopiedEventAtDraftSelection();
|
||||
}
|
||||
}
|
||||
|
||||
useCalendarClock(setNow);
|
||||
useCalendarEditingReset({ editing, previousEditingRef, resetDraft, setActiveEventId, setDraft, setError, setSelection });
|
||||
usePruneExpiredCalendarEvents({ activeEventId, data, rangeEnd, rangeStart, resetDraft, save });
|
||||
|
||||
return {
|
||||
initializeWeek,
|
||||
initializeMonth,
|
||||
contentProps: {
|
||||
data,
|
||||
context,
|
||||
draft: { ...draft, alertOffsetMinutes: draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes) },
|
||||
error,
|
||||
activeOccurrenceDate,
|
||||
monthStart,
|
||||
monthGridDays,
|
||||
occurrences,
|
||||
textContent,
|
||||
activeEventId,
|
||||
formOpen,
|
||||
draftSelection,
|
||||
selection,
|
||||
monthSelection,
|
||||
days,
|
||||
weekGridLayout,
|
||||
now,
|
||||
nowDayIndex,
|
||||
nowMinutes,
|
||||
hideEmptyHours,
|
||||
onKeyDown: handleKeyDown,
|
||||
onSubmit: submitEvent,
|
||||
onDraftChange: updateDraft,
|
||||
onAlertModeChange: updateAlertMode,
|
||||
onAlertOffsetToggle: toggleAlertOffset,
|
||||
onDeleteEvent: deleteEvent,
|
||||
onDeleteOccurrence: deleteOccurrence,
|
||||
onClose: closeForm,
|
||||
onDisplayedMonthChange: setDisplayedMonth,
|
||||
onImport: (nextData) => save(nextData),
|
||||
onHideEmptyHoursToggle: () => setHideEmptyHours((current) => !current),
|
||||
onMonthPointerDown: handleMonthPointerDown,
|
||||
onMonthPointerEnter: handleMonthPointerEnter,
|
||||
onMonthPointerUp: () => setMonthSelection(null),
|
||||
onSlotPointerDown: handleSlotPointerDown,
|
||||
onSlotPointerEnter: handleSlotPointerEnter,
|
||||
onSlotPointerUp: () => setSelection(null),
|
||||
onEditEvent: editEvent
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Rôle : centralise les mises à jour de draft et d'alertes du calendrier.
|
||||
import { normalizeDraftAlertOffsets } from "./calendarDraft.js";
|
||||
|
||||
export function useCalendarDraftControls({ setDraft, setError }) {
|
||||
function updateDraft(values) {
|
||||
setDraft((current) => ({ ...current, ...values }));
|
||||
setError("");
|
||||
}
|
||||
|
||||
function updateAlertMode(alertMode) {
|
||||
setDraft((current) => {
|
||||
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
|
||||
return {
|
||||
...current,
|
||||
alertMode,
|
||||
alertOffsetMinutes: alertMode === "off" ? [] : currentOffsets.length ? currentOffsets : [10]
|
||||
};
|
||||
});
|
||||
setError("");
|
||||
}
|
||||
|
||||
function toggleAlertOffset(offset) {
|
||||
setDraft((current) => {
|
||||
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
|
||||
const nextOffsets = currentOffsets.includes(offset)
|
||||
? currentOffsets.filter((item) => item !== offset)
|
||||
: [...currentOffsets, offset].sort((a, b) => b - a);
|
||||
return {
|
||||
...current,
|
||||
alertMode: nextOffsets.length ? current.alertMode === "off" ? "visible" : current.alertMode : "off",
|
||||
alertOffsetMinutes: nextOffsets
|
||||
};
|
||||
});
|
||||
setError("");
|
||||
}
|
||||
|
||||
return { updateAlertMode, updateDraft, toggleAlertOffset };
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// Rôle : regroupe les effets de cycle de vie du contrôleur calendrier.
|
||||
import { useEffect } from "react";
|
||||
import { getCalendarOccurrences } from "./calendarUtils.js";
|
||||
import { createDefaultDraft } from "./calendarDraft.js";
|
||||
|
||||
export function useCalendarClock(setNow) {
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => setNow(new Date()), 60000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [setNow]);
|
||||
}
|
||||
|
||||
export function useCalendarEditingReset({
|
||||
editing,
|
||||
previousEditingRef,
|
||||
resetDraft,
|
||||
setActiveEventId,
|
||||
setDraft,
|
||||
setError,
|
||||
setSelection
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const previousEditing = previousEditingRef.current;
|
||||
previousEditingRef.current = editing;
|
||||
if (!previousEditing && editing) {
|
||||
setDraft(createDefaultDraft());
|
||||
setActiveEventId("");
|
||||
setSelection(null);
|
||||
setError("");
|
||||
}
|
||||
if (previousEditing && !editing) {
|
||||
resetDraft();
|
||||
}
|
||||
}, [editing, previousEditingRef, resetDraft, setActiveEventId, setDraft, setError, setSelection]);
|
||||
}
|
||||
|
||||
export function usePruneExpiredCalendarEvents({
|
||||
activeEventId,
|
||||
data,
|
||||
rangeEnd,
|
||||
rangeStart,
|
||||
resetDraft,
|
||||
save
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!data.events.length) return undefined;
|
||||
function pruneExpiredOneShotEvents() {
|
||||
const nowMs = Date.now();
|
||||
const nextEvents = data.events.filter((event) => {
|
||||
if (event.repeatable !== false) return true;
|
||||
const occurrence = getCalendarOccurrences([event], rangeStart, rangeEnd)[0];
|
||||
return !occurrence || occurrence.endMs > nowMs;
|
||||
});
|
||||
if (nextEvents.length === data.events.length) return;
|
||||
save({ ...data, initialized: true, events: nextEvents });
|
||||
if (activeEventId && !nextEvents.some((event) => event.id === activeEventId)) resetDraft();
|
||||
}
|
||||
pruneExpiredOneShotEvents();
|
||||
const intervalId = window.setInterval(pruneExpiredOneShotEvents, 30000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [activeEventId, data, rangeEnd, rangeStart, resetDraft, save]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue