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 { exportModuleText, importModuleText } from "../website/src/features/toolboxes/modules/textImport.js";
|
||||||
import { compactCalendarDataForStorage, normalizeCalendarData } from "../website/src/features/toolboxes/storage/modules/calendar.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 () => {
|
test("toolbox storage, cards and pages are wired", async () => {
|
||||||
const source = await readFile("website/src/main.jsx", "utf8");
|
const source = await readFile("website/src/main.jsx", "utf8");
|
||||||
const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.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 () => {
|
test("toolbox module registry and modules expose expected behavior", async () => {
|
||||||
const source = await readFile("website/src/main.jsx", "utf8");
|
const source = await readFile("website/src/main.jsx", "utf8");
|
||||||
const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.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 moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8");
|
||||||
const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "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");
|
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 tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
|
||||||
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "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 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 timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
|
||||||
const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "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 calendarContent = await readFile("website/src/features/toolboxes/modules/CalendarContent.jsx", "utf8");
|
||||||
const calendarDraft = await readFile("website/src/features/toolboxes/modules/calendarDraft.js", "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 calendarEventForm = await readFile("website/src/features/toolboxes/modules/CalendarEventForm.jsx", "utf8");
|
||||||
const calendarEventControls = await readFile("website/src/features/toolboxes/modules/CalendarEventControls.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 calendarMonthDatePicker = await readFile("website/src/features/toolboxes/modules/CalendarMonthDatePicker.jsx", "utf8");
|
||||||
const calendarViews = await readFile("website/src/features/toolboxes/modules/CalendarViews.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 calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "utf8");
|
||||||
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "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 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 taskPlannerItemPanels = await readFile("website/src/features/toolboxes/modules/TaskPlannerItemPanels.jsx", "utf8");
|
||||||
const taskPlannerReorder = await readFile("website/src/features/toolboxes/modules/useTaskPlannerReorder.js", "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 taskPlannerTopbar = await readFile("website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx", "utf8");
|
||||||
const taskPlannerSettings = await readFile("website/src/features/toolboxes/modules/TaskPlannerSettings.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 taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
|
||||||
const goalTreeModule = await readFile("website/src/features/toolboxes/modules/GoalTreeModule.jsx", "utf8");
|
const goalTreeModule = await readFile("website/src/features/toolboxes/modules/GoalTreeModule.jsx", "utf8");
|
||||||
const goalTreeEditor = await readFile("website/src/features/toolboxes/modules/GoalTreeEditor.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 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 goalTreeController = await readFile("website/src/features/toolboxes/modules/useGoalTreeController.js", "utf8");
|
||||||
const goalTreeView = await readFile("website/src/features/toolboxes/modules/GoalTreeView.jsx", "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 goalTreeDisplay = await readFile("website/src/features/toolboxes/modules/goalTreeDisplay.js", "utf8");
|
||||||
const goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.js", "utf8");
|
const goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.js", "utf8");
|
||||||
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8");
|
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8");
|
||||||
|
|
@ -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 textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8");
|
||||||
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "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_MODULES/);
|
||||||
assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULE_DEFINITIONS/);
|
assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULE_DEFINITIONS/);
|
||||||
assert.match(moduleMetadataRegistry, /export function getDefaultModuleTitle/);
|
assert.match(moduleMetadataRegistry, /export function getDefaultModuleTitle/);
|
||||||
assert.match(moduleRegistry, /export function AddToolControls/);
|
assert.match(moduleRegistry, /export \{ AddToolControls \}/);
|
||||||
assert.match(moduleRegistry, /export function ToolboxModules/);
|
assert.match(moduleRegistry, /export \{ ToolboxModules \}/);
|
||||||
assert.match(moduleRegistry, /MODULE_COMPONENTS/);
|
assert.match(moduleComponents, /MODULE_COMPONENTS/);
|
||||||
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/CombosModule\.jsx"\)/);
|
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/CombosModule\.jsx"\)/);
|
||||||
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/CalendarModule\.jsx"\)/);
|
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/CalendarModule\.jsx"\)/);
|
||||||
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/GoalTreeModule\.jsx"\)/);
|
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/GoalTreeModule\.jsx"\)/);
|
||||||
assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/EquipmentPlannerModule\.jsx"\)/);
|
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/EquipmentPlannerModule\.jsx"\)/);
|
||||||
assert.match(moduleRegistry, /Suspense fallback=\{<ModuleLoadingFallback label=\{label\} \/>\}/);
|
assert.match(toolboxModules, /Suspense fallback=\{<ModuleLoadingFallback label=\{label\} \/>\}/);
|
||||||
assert.match(moduleRegistry, /LAZY_MODULE_TYPES/);
|
assert.match(moduleComponents, /LAZY_MODULE_TYPES/);
|
||||||
assert.match(moduleRegistry, /notepad:/);
|
assert.match(moduleComponents, /notepad:/);
|
||||||
assert.match(moduleRegistry, /checklist:/);
|
assert.match(moduleComponents, /checklist:/);
|
||||||
assert.match(moduleRegistry, /images:/);
|
assert.match(moduleComponents, /images:/);
|
||||||
assert.match(moduleRegistry, /links:/);
|
assert.match(moduleComponents, /links:/);
|
||||||
assert.match(moduleRegistry, /counters:/);
|
assert.match(moduleComponents, /counters:/);
|
||||||
assert.match(moduleRegistry, /combos:/);
|
assert.match(moduleComponents, /combos:/);
|
||||||
assert.match(moduleRegistry, /CombosModule/);
|
assert.match(moduleComponents, /CombosModule/);
|
||||||
assert.match(moduleRegistry, /calculator:/);
|
assert.match(moduleComponents, /calculator:/);
|
||||||
assert.match(moduleRegistry, /table:/);
|
assert.match(moduleComponents, /table:/);
|
||||||
assert.match(moduleRegistry, /table: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.table, Component: TableModule \}/);
|
assert.match(moduleComponents, /table: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.table, Component: TableModule \}/);
|
||||||
assert.match(moduleRegistry, /timer:/);
|
assert.match(moduleComponents, /timer:/);
|
||||||
assert.match(moduleRegistry, /calendar:/);
|
assert.match(moduleComponents, /calendar:/);
|
||||||
assert.match(moduleRegistry, /taskPlanner:/);
|
assert.match(moduleComponents, /taskPlanner:/);
|
||||||
assert.match(moduleRegistry, /goalTree:/);
|
assert.match(moduleComponents, /goalTree:/);
|
||||||
assert.match(moduleRegistry, /equipmentPlanner:/);
|
assert.match(moduleComponents, /equipmentPlanner:/);
|
||||||
assert.match(moduleMetadataRegistry, /Planificateur d'équipements/);
|
assert.match(moduleMetadataRegistry, /Planificateur d'équipements/);
|
||||||
assert.match(moduleRegistry, /imageAnnotation:/);
|
assert.match(moduleComponents, /imageAnnotation:/);
|
||||||
assert.match(moduleMetadataRegistry, /Annotation d'images/);
|
assert.match(moduleMetadataRegistry, /Annotation d'images/);
|
||||||
assert.match(moduleRegistry, /notepad: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.notepad, Component: NotepadModule \}/);
|
assert.match(moduleComponents, /notepad: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.notepad, Component: NotepadModule \}/);
|
||||||
assert.match(moduleRegistry, /module-edit-button/);
|
assert.match(toolboxModules, /module-edit-button/);
|
||||||
assert.match(moduleRegistry, /itemReorder\.startDrag/);
|
assert.match(toolboxModules, /itemReorder\.startDrag/);
|
||||||
assert.match(moduleRegistry, /onPointerDown/);
|
assert.match(moduleRegistryBundle, /onPointerDown/);
|
||||||
assert.match(moduleRegistry, /useGroupedReorder/);
|
assert.match(toolboxModules, /useGroupedReorder/);
|
||||||
assert.match(moduleRegistry, /useInlineEdit/);
|
assert.match(toolboxModules, /useInlineEdit/);
|
||||||
assert.match(moduleRegistry, /getContentEditableProps/);
|
assert.match(toolboxModules, /getContentEditableProps/);
|
||||||
assert.match(moduleRegistry, /tool-add-card/);
|
assert.match(addToolControls, /tool-add-card/);
|
||||||
assert.match(moduleRegistry, /tool-quick-add-button/);
|
assert.match(addToolControls, /tool-quick-add-button/);
|
||||||
assert.match(moduleRegistry, /tool-add-quick-toggle/);
|
assert.match(addToolControls, /tool-add-quick-toggle/);
|
||||||
assert.match(moduleMetadataRegistry, /scrollable: true/);
|
assert.match(moduleMetadataRegistry, /scrollable: true/);
|
||||||
assert.match(moduleRegistry, /module-content/);
|
assert.match(toolboxModules, /module-content/);
|
||||||
assert.match(moduleRegistry, /module-scroll-button/);
|
assert.match(toolboxModules, /module-scroll-button/);
|
||||||
assert.match(moduleRegistry, /Icon name="dropdown"/);
|
assert.match(addToolControls, /Icon name="dropdown"/);
|
||||||
assert.match(moduleRegistry, /CompactDropdown/);
|
assert.match(addToolControls, /CompactDropdown/);
|
||||||
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
|
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
|
||||||
assert.match(notepadModule, /export function NotepadModule/);
|
assert.match(notepadModule, /export function NotepadModule/);
|
||||||
assert.match(notepadModule, /TextExchangeActions/);
|
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, /Tabs/);
|
||||||
assert.match(timerModule, /stopwatch/);
|
assert.match(timerModule, /stopwatch/);
|
||||||
assert.match(timerModule, /countdown/);
|
assert.match(timerModule, /countdown/);
|
||||||
assert.match(timerParts, /useInlineEdit/);
|
assert.match(timerBundle, /useInlineEdit/);
|
||||||
assert.match(timerModule, /useDraftForm/);
|
assert.match(timerModule, /useDraftForm/);
|
||||||
assert.match(timerModule, /normalizeTimerData/);
|
assert.match(timerModule, /normalizeTimerData/);
|
||||||
assert.match(timerModule, /TextExchangeActions/);
|
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, /MonthDateDropdown/);
|
||||||
assert.match(calendarBundle, /MonthEventRepeatSwitch/);
|
assert.match(calendarBundle, /MonthEventRepeatSwitch/);
|
||||||
assert.match(calendarBundle, /monthlyRepeatBy/);
|
assert.match(calendarBundle, /monthlyRepeatBy/);
|
||||||
assert.match(calendarModule, /displayedMonth/);
|
assert.match(calendarController, /displayedMonth/);
|
||||||
assert.match(calendarModule, /deleteOccurrence/);
|
assert.match(calendarController, /deleteOccurrence/);
|
||||||
assert.match(calendarBundle, /onPointerDown/);
|
assert.match(calendarBundle, /onPointerDown/);
|
||||||
assert.match(calendarModule, /copyEvent/);
|
assert.match(calendarController, /copyEvent/);
|
||||||
assert.match(calendarBundle, /calendar-repeat-switch/);
|
assert.match(calendarBundle, /calendar-repeat-switch/);
|
||||||
assert.match(calendarBundle, /layout-switch calendar-month-repeat-switch/);
|
assert.match(calendarBundle, /layout-switch calendar-month-repeat-switch/);
|
||||||
assert.match(calendarBundle, /WEEK_TIME_INPUT_STEP_MINUTES \* 60/);
|
assert.match(calendarBundle, /WEEK_TIME_INPUT_STEP_MINUTES \* 60/);
|
||||||
assert.match(calendarBundle, /durationMinutes < 120/);
|
assert.match(calendarBundle, /durationMinutes < 120/);
|
||||||
assert.match(calendarBundle, /is-compact/);
|
assert.match(calendarBundle, /is-compact/);
|
||||||
assert.match(calendarModule, /handleKeyDown/);
|
assert.match(calendarController, /handleKeyDown/);
|
||||||
assert.match(calendarContent, /TextExchangeActions/);
|
assert.match(calendarContent, /TextExchangeActions/);
|
||||||
assert.match(calendarContent, /type="calendar"/);
|
assert.match(calendarContent, /type="calendar"/);
|
||||||
assert.match(calendarBundle, /repeatLabel/);
|
assert.match(calendarBundle, /repeatLabel/);
|
||||||
|
|
@ -408,8 +460,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(taskPlannerModule, /useDraftForm/);
|
assert.match(taskPlannerModule, /useDraftForm/);
|
||||||
assert.match(taskPlannerBundle, /TextExchangeActions/);
|
assert.match(taskPlannerBundle, /TextExchangeActions/);
|
||||||
assert.match(taskPlannerBundle, /reorderFeatures/);
|
assert.match(taskPlannerBundle, /reorderFeatures/);
|
||||||
assert.match(taskPlannerItems, /export function TaskPlannerBranch/);
|
assert.match(taskPlannerBranch, /export function TaskPlannerBranch/);
|
||||||
assert.match(taskPlannerItems, /function TaskPlannerItem/);
|
assert.match(taskPlannerItem, /export function TaskPlannerItem/);
|
||||||
assert.match(taskPlannerBundle, /task-planner-warning/);
|
assert.match(taskPlannerBundle, /task-planner-warning/);
|
||||||
assert.match(taskPlannerBundle, /useInlineEdit/);
|
assert.match(taskPlannerBundle, /useInlineEdit/);
|
||||||
assert.match(taskPlannerBundle, /commitOnEnter: false/);
|
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, /Tabs/);
|
||||||
assert.match(goalTreeEditor, /GoalTreeFormulaInput/);
|
assert.match(goalTreeEditor, /GoalTreeFormulaInput/);
|
||||||
assert.match(goalTreeEditor, /GoalTreePrerequisitePicker/);
|
assert.match(goalTreeEditor, /GoalTreePrerequisitePicker/);
|
||||||
assert.match(goalTreeParts, /Tooltip/);
|
assert.match(goalTreeBranch, /Tooltip/);
|
||||||
assert.match(goalTreeParts, /export function GoalTreeIconPicker/);
|
assert.match(goalTreeEditorFields, /export function GoalTreeIconPicker/);
|
||||||
assert.match(goalTreeParts, /export function GoalTreeContentEditor/);
|
assert.match(goalTreeEditorFields, /export function GoalTreeContentEditor/);
|
||||||
assert.match(goalTreeParts, /function GoalTreeNodeContentPreview/);
|
assert.match(goalTreeNodeContent, /export function GoalTreeNodeContentPreview/);
|
||||||
assert.doesNotMatch(goalTreeBundle, /\.slice\(0, 3\)/);
|
assert.doesNotMatch(goalTreeBundle, /\.slice\(0, 3\)/);
|
||||||
assert.match(goalTreeParts, /GoalTreeChecklistInlineControl/);
|
assert.match(goalTreeNodeContent, /GoalTreeChecklistInlineControl/);
|
||||||
assert.match(goalTreeParts, /GoalTreeStepLeafControl/);
|
assert.match(goalTreeNodeContent, /GoalTreeStepLeafControl/);
|
||||||
assert.match(goalTreeParts, /export function GoalTreePrerequisitePicker/);
|
assert.match(goalTreeEditorFields, /export function GoalTreePrerequisitePicker/);
|
||||||
assert.match(goalTreeBundle, /function toggleBranch/);
|
assert.match(goalTreeBundle, /function toggleBranch/);
|
||||||
assert.match(goalTreeBundle, /function editNode/);
|
assert.match(goalTreeBundle, /function editNode/);
|
||||||
assert.match(goalTreeBundle, /function addNodeContentItem/);
|
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, /progress\.current/);
|
||||||
assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
|
assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
|
||||||
assert.doesNotMatch(goalTreeUtils, /"calculator"/);
|
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 calculateGoalTreeFormula/);
|
||||||
assert.match(goalTreeUtils, /export function evaluateGoalTreeNodes/);
|
assert.match(goalTreeUtils, /export function evaluateGoalTreeNodes/);
|
||||||
assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/);
|
assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/);
|
||||||
|
|
@ -540,10 +592,10 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(timerModule, /useGroupedReorder/);
|
assert.match(timerModule, /useGroupedReorder/);
|
||||||
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
|
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
|
||||||
assert.match(timerModule, /parentId=\{moduleId\}/);
|
assert.match(timerModule, /parentId=\{moduleId\}/);
|
||||||
assert.match(timerParts, /timer-drag-handle/);
|
assert.match(timerCountdown, /timer-drag-handle/);
|
||||||
assert.match(timerParts, /export function StopwatchControls/);
|
assert.match(timerStopwatch, /export function StopwatchControls/);
|
||||||
assert.match(timerParts, /export function CountdownControls/);
|
assert.match(timerCountdown, /export function CountdownControls/);
|
||||||
assert.match(timerParts, /export function CountdownList/);
|
assert.match(timerCountdown, /export function CountdownList/);
|
||||||
assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/);
|
assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/);
|
||||||
assert.match(textImport, /export \{ parseColonImportLines \}/);
|
assert.match(textImport, /export \{ parseColonImportLines \}/);
|
||||||
assert.match(textImport, /export function exportModuleText/);
|
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.
|
// Rôle : orchestre l'outil calendrier et délègue l'état métier à son contrôleur.
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { CalendarContent } from "./CalendarContent.jsx";
|
import { CalendarContent } from "./CalendarContent.jsx";
|
||||||
import {
|
import { CalendarModeChoice } from "./CalendarModeChoice.jsx";
|
||||||
CALENDAR_ALERT_MODES,
|
import { useCalendarController } from "./useCalendarController.js";
|
||||||
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";
|
|
||||||
|
|
||||||
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
|
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
|
||||||
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
|
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
|
||||||
const textContent = context.moduleText?.calendar || {};
|
const textContent = context.moduleText?.calendar || {};
|
||||||
const [now, setNow] = useState(() => new Date());
|
const controller = useCalendarController({ data, context, toolboxId, moduleId, editing, setEditing, textContent });
|
||||||
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]);
|
|
||||||
|
|
||||||
if (!data.initialized) {
|
if (!data.initialized) {
|
||||||
return (
|
return (
|
||||||
<div className="calendar-module">
|
<CalendarModeChoice
|
||||||
<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}
|
|
||||||
textContent={textContent}
|
textContent={textContent}
|
||||||
activeEventId={activeEventId}
|
onInitializeWeek={controller.initializeWeek}
|
||||||
formOpen={formOpen}
|
onInitializeMonth={controller.initializeMonth}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Rôle : conserve les exports publics historiques des sous-composants goal tree.
|
||||||
import { useState } from "react";
|
export { GoalTreeBranch } from "./GoalTreeBranch.jsx";
|
||||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
export {
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
GoalTreeContentEditor,
|
||||||
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
|
GoalTreeFormulaInput,
|
||||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
GoalTreeIconPicker,
|
||||||
import {
|
GoalTreePrerequisitePicker,
|
||||||
GOAL_TREE_ICON_NAMES,
|
GoalTreeTitleInput
|
||||||
formatGoalTreeNumber,
|
} from "./GoalTreeEditorFields.jsx";
|
||||||
getGoalTreeChildren,
|
export {
|
||||||
getGoalTreeDescendantIds,
|
GoalTreeNodeContentPreview,
|
||||||
getGoalTreeParentIds
|
GoalTreeStepLeafControl
|
||||||
} from "./goalTreeUtils.js";
|
} from "./GoalTreeNodeContent.jsx";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
|
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { GoalTreeBranch } from "./GoalTreeParts.jsx";
|
import { GoalTreeBranch } from "./GoalTreeBranch.jsx";
|
||||||
import { getVisibleGoalTreeEdges } from "./goalTreeDisplay.js";
|
import { getVisibleGoalTreeEdges } from "./goalTreeDisplay.js";
|
||||||
|
|
||||||
export function GoalTreeView({
|
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.
|
// Rôle : conserve les exports publics historiques des sous-composants task planner.
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
export { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
export { TaskPlannerItem } from "./TaskPlannerItem.jsx";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
|
// 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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||||
import { TaskPlannerBranch } from "./TaskPlannerItems.jsx";
|
import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
|
||||||
import { TaskPlannerSettings } from "./TaskPlannerSettings.jsx";
|
import { TaskPlannerSettings } from "./TaskPlannerSettings.jsx";
|
||||||
import { TaskPlannerTopbar } from "./TaskPlannerTopbar.jsx";
|
import { TaskPlannerTopbar } from "./TaskPlannerTopbar.jsx";
|
||||||
import {
|
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
|
timePartsToString
|
||||||
} from "./timerUtils.js";
|
} from "./timerUtils.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
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 {
|
import {
|
||||||
ALERT_MIN_DELAY_MS,
|
ALERT_MIN_DELAY_MS,
|
||||||
AUTO_REFRESH_COUNTDOWN_TYPES,
|
AUTO_REFRESH_COUNTDOWN_TYPES,
|
||||||
|
|
|
||||||
|
|
@ -1,350 +1,4 @@
|
||||||
// Rôle : rend les contrôles, valeurs et listes de résultats du module Timer.
|
// Rôle : conserve les exports publics historiques des sous-composants timer.
|
||||||
import { useState } from "react";
|
export { CountdownControls, CountdownList } from "./TimerCountdown.jsx";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
export { EditableTimerLabel, TimerAlertModeSwitch, TimerValue, TimePartsInput } from "./TimerSharedControls.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
export { StopwatchControls, StopwatchLapList } from "./TimerStopwatch.jsx";
|
||||||
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
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Rôle : expose l'API publique du registre et du rendu des modules toolbox.
|
||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
export { TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
|
||||||
import { createPortal } from "react-dom";
|
export { AddToolControls } from "./AddToolControls.jsx";
|
||||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
export { ToolboxModules } from "./ToolboxModules.jsx";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
export { LAZY_MODULE_TYPES, MODULE_COMPONENTS } from "./toolboxModuleComponents.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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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