Improve toolbox reorder and checklist editing UX
All checks were successful
Deploy Sokko G / deploy (push) Successful in 8s

This commit is contained in:
Shinuwa 2026-08-02 22:12:33 +02:00
parent 1e4ac4259b
commit 8eb622e05d
11 changed files with 695 additions and 140 deletions

View file

@ -1,6 +1,8 @@
// Rôle : fournit l'outil checklist avec quantités, catégories et imports texte.
import { useCallback, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextImportModal } from "./TextImportModal.jsx";
import { parseColonImportLines } from "./textImport.js";
@ -15,6 +17,49 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
const closeImportModal = useCallback(() => setImportOpen(false), []);
const namedSections = data.sections.filter((section) => section.title);
const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible";
const reorderItems = getChecklistReorderItems(data.sections);
const {
itemReorder,
groupReorder,
getItemProps,
getGroupProps,
getGroupBoundaryProps,
isItemDragging,
isItemDropTarget,
isGroupDragging,
isGroupDropTarget,
isBoundaryDropTarget,
getDropPlacement,
shouldShowGroupBoundaries
} = useGroupedReorder({
namespace: `checklist-${moduleId}`,
items: reorderItems,
getItemId: (item) => item.id,
getItemGroup: (item) => item.sectionId,
getEffectiveGroup: (item) => item.sectionId,
reorderFeatures: {
item: { groupChange: true, boundaryDrop: true },
group: { reorder: true, boundaryDrop: true }
},
onItemMove: (operation) => save(moveChecklistItem(data.sections, operation, context)),
onGroupMove: (operation) => save(moveItem(data.sections, operation.sourceId, operation.targetId, operation.placement))
});
const reorder = {
itemReorder,
groupReorder,
getItemProps,
getGroupProps,
getGroupBoundaryProps,
isItemDragging,
isItemDropTarget,
isGroupDragging,
isGroupDropTarget,
isBoundaryDropTarget,
getDropPlacement,
shouldShowGroupBoundaries
};
const showBoundaryDropZones = reorder.shouldShowGroupBoundaries();
const visibleSections = data.sections.filter((section) => !(section.title && data.hideCompletedSectionsFully && isSectionComplete(section, context)));
function save(sections, settings = {}) {
context.setModuleData(toolboxId, moduleId, { ...data, ...settings, sections });
@ -57,6 +102,26 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
});
}
function updateSection(sectionId, updater) {
save(data.sections.map((section) => section.id === sectionId ? updater(section) : section).filter(keepChecklistSection));
}
function renameSection(sectionId, title) {
save(renameChecklistSection(data.sections, sectionId, title));
}
function updateItem(itemId, updater) {
save(updateChecklistItem(data.sections, itemId, updater));
}
function moveItemToCategory(itemId, title) {
save(moveChecklistItemToCategory(data.sections, itemId, title, context));
}
function deleteItem(itemId) {
save(data.sections.map((section) => ({ ...section, items: section.items.filter((item) => item.id !== itemId) })).filter(keepChecklistSection));
}
return (
<>
{editing && (
@ -109,9 +174,38 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
</div>
)}
<ul className="checklist">
{data.sections.map((section) => (
<ChecklistSection key={section.id} section={section} sections={data.sections} data={data} context={context} save={save} />
))}
{visibleSections.map((section, index) => {
const isLastSection = index === visibleSections.length - 1;
return [
showBoundaryDropZones ? (
<li
className={`checklist-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: section.id, placement: "before" }) ? "is-drop-target" : ""}`}
key={`${section.id}:before-drop`}
{...reorder.getGroupBoundaryProps({ groupId: section.id, placement: "before" })}
/>
) : null,
<ChecklistSection
key={section.id}
section={section}
data={data}
context={context}
reorder={reorder}
onRenameSection={renameSection}
onUpdateSection={updateSection}
onSaveSections={save}
onUpdateItem={updateItem}
onMoveItemToCategory={moveItemToCategory}
onDeleteItem={deleteItem}
/>,
showBoundaryDropZones && isLastSection ? (
<li
className={`checklist-category-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: section.id, placement: "after" }) ? "is-drop-target" : ""}`}
key={`${section.id}:after-drop`}
{...reorder.getGroupBoundaryProps({ groupId: section.id, placement: "after" })}
/>
) : null
];
})}
</ul>
</>
);
@ -151,30 +245,142 @@ function getSectionHideWhenComplete(section, globalHide) {
return typeof section.hideWhenComplete === "boolean" ? section.hideWhenComplete : globalHide;
}
function CategoryTitle({ title }) {
return String(title || "").split(/([♂♀])/g).map((part, index) => (
part === "♂" || part === "♀"
? <span className="checklist-gender-symbol" key={index}>{part}</span>
: part
));
function keepChecklistSection(section) {
return section.items.length || section.title;
}
function ChecklistSection({ section, context, sections, data, save }) {
function getChecklistReorderItems(sections) {
return sections.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })));
}
function findChecklistSectionByItemId(sections, itemId) {
return sections.find((section) => section.items.some((item) => item.id === itemId));
}
function renameChecklistSection(sections, sectionId, title) {
const cleanTitle = title.trim();
const section = sections.find((entry) => entry.id === sectionId);
if (!section) return sections;
const existingSection = sections.find((entry) => entry.id !== sectionId && entry.title.toLowerCase() === cleanTitle.toLowerCase());
if (!existingSection) {
return sections.map((entry) => entry.id === sectionId ? { ...entry, title: cleanTitle } : entry).filter(keepChecklistSection);
}
return sections.map((entry) => {
if (entry.id === existingSection.id) return { ...entry, items: [...entry.items, ...section.items] };
return entry;
}).filter((entry) => entry.id !== sectionId).filter(keepChecklistSection);
}
function updateChecklistItem(sections, itemId, updater) {
return sections.map((section) => ({
...section,
items: section.items.map((item) => item.id === itemId ? updater(item) : item).filter((item) => item.label)
})).filter(keepChecklistSection);
}
function moveChecklistItemToCategory(sections, itemId, title, context) {
const cleanTitle = title.trim();
const sourceSection = findChecklistSectionByItemId(sections, itemId);
const item = sourceSection?.items.find((entry) => entry.id === itemId);
if (!sourceSection || !item || sourceSection.title.toLowerCase() === cleanTitle.toLowerCase()) return sections;
const targetSection = sections.find((section) => section.title.toLowerCase() === cleanTitle.toLowerCase());
const sectionsWithoutItem = sections.map((section) => ({
...section,
items: section.items.filter((entry) => entry.id !== itemId)
})).filter(keepChecklistSection);
if (targetSection) {
return sectionsWithoutItem.map((section) => section.id === targetSection.id ? { ...section, items: [...section.items, item] } : section);
}
return [...sectionsWithoutItem, { id: context.uid("section"), title: cleanTitle, items: [item] }];
}
function moveChecklistItem(sections, operation, context) {
const sourceSection = findChecklistSectionByItemId(sections, operation.sourceId);
const sourceItem = sourceSection?.items.find((item) => item.id === operation.sourceId);
if (!sourceSection || !sourceItem) return sections;
if (operation.targetType === "boundary") return moveChecklistItemToUngroupedBoundary(sections, sourceSection, sourceItem, operation, context);
const targetSection = operation.targetType === "item"
? findChecklistSectionByItemId(sections, operation.targetId)
: sections.find((section) => section.id === operation.targetGroup);
if (!targetSection) return sections;
const sectionsWithoutItem = sections.map((section) => ({
...section,
items: section.items.filter((item) => item.id !== operation.sourceId)
})).filter(keepChecklistSection);
return sectionsWithoutItem.map((section) => {
if (section.id !== targetSection.id) return section;
if (operation.targetType === "item") {
const targetIndex = section.items.findIndex((item) => item.id === operation.targetId);
if (targetIndex < 0) return { ...section, items: [...section.items, sourceItem] };
const nextItems = [...section.items];
nextItems.splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceItem);
return { ...section, items: nextItems };
}
return {
...section,
items: operation.placement === "after" ? [...section.items, sourceItem] : [sourceItem, ...section.items]
};
});
}
function moveChecklistItemToUngroupedBoundary(sections, sourceSection, sourceItem, operation, context) {
const targetIndex = sections.findIndex((section) => section.id === operation.targetGroup);
if (targetIndex < 0) return sections;
const preferredUngroupedSection = sourceSection.title ? sections.find((section) => !section.title && section.id !== sourceSection.id) : sourceSection;
const ungroupedSectionId = preferredUngroupedSection?.id || context.uid("section");
const baseSections = sections
.map((section) => ({
...section,
items: section.items.filter((item) => item.id !== sourceItem.id)
}))
.filter((section) => section.id !== ungroupedSectionId)
.filter(keepChecklistSection);
const targetSection = sections[targetIndex];
const nextTargetIndex = baseSections.findIndex((section) => section.id === targetSection.id);
const insertIndex = nextTargetIndex < 0
? Math.min(targetIndex, baseSections.length)
: nextTargetIndex + (operation.placement === "after" ? 1 : 0);
const ungroupedSection = {
id: ungroupedSectionId,
title: "",
items: [...(preferredUngroupedSection?.items || []).filter((item) => item.id !== sourceItem.id), sourceItem]
};
const nextSections = [...baseSections];
nextSections.splice(insertIndex, 0, ungroupedSection);
return nextSections;
}
function ChecklistSection({ section, context, data, reorder, onRenameSection, onUpdateSection, onSaveSections, onUpdateItem, onMoveItemToCategory, onDeleteItem }) {
const showTitle = Boolean(section.title);
const sectionComplete = showTitle && isSectionComplete(section, context);
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
const isAutoCollapsed = sectionComplete && hideWhenComplete;
const isCollapsed = Boolean(section.collapsed) || isAutoCollapsed;
const textContent = context.moduleText?.checklist || {};
const className = [
"checklist-section",
showTitle ? "is-grouped" : "",
sectionComplete ? "is-complete" : "",
isCollapsed ? "is-collapsed" : "",
reorder.isGroupDragging(section.id) ? "is-dragging" : "",
reorder.isGroupDropTarget(section.id) ? "is-drop-target" : "",
reorder.getDropPlacement("group", section.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
const titleEdit = useInlineEdit({
value: section.title,
onCommit: (title) => onRenameSection(section.id, title)
});
const [editingItemId, setEditingItemId] = useState("");
if (sectionComplete && data.hideCompletedSectionsFully) return null;
function saveItems(items) {
save(sections.map((entry) => entry.id === section.id ? { ...entry, items } : entry).filter((entry) => entry.items.length || entry.title));
onUpdateSection(section.id, (entry) => ({ ...entry, items }));
}
function toggleSectionCollapsed() {
const nextCollapsed = !isCollapsed;
const nextSections = sections.map((entry) => {
const nextSections = data.sections.map((entry) => {
if (entry.id === section.id) {
const nextSection = { ...entry };
if (nextCollapsed) {
@ -195,23 +401,40 @@ function ChecklistSection({ section, context, sections, data, save }) {
});
const nextCompletedSections = nextSections.filter((entry) => entry.title && isSectionComplete(entry, context));
const allHidden = nextCompletedSections.length > 0 && nextCompletedSections.every((entry) => getSectionHideWhenComplete(entry, false));
save(nextSections, { hideCompletedSections: allHidden });
onSaveSections(nextSections, { hideCompletedSections: allHidden });
}
return (
<li className={`checklist-section ${showTitle ? "is-grouped" : ""} ${sectionComplete ? "is-complete" : ""} ${isCollapsed ? "is-collapsed" : ""}`}>
<li className={className} {...reorder.getGroupProps({ groupId: section.id })}>
{showTitle && (
<div className="checklist-section-header">
<h3><CategoryTitle title={section.title || "Sans catégorie"} /></h3>
<div>
<button
className="checklist-drag-handle"
type="button"
onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: section.id })}
aria-label={`${textContent.reorderSectionTitle || "Déplacer la catégorie"} ${section.title}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
<h3>
<input
{...titleEdit.getInputProps({
className: "checklist-section-title-input",
title: textContent.sectionEditTitle || "Renommer la catégorie",
"aria-label": textContent.sectionEditTitle || "Renommer la catégorie"
})}
/>
</h3>
<div className="checklist-section-actions">
<span>{section.items.filter((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget).length} / {section.items.length}</span>
<button
className="checklist-section-collapse-button"
type="button"
onClick={toggleSectionCollapsed}
aria-expanded={!isCollapsed}
title={isCollapsed ? context.moduleText?.checklist?.showCompletedSectionTitle || "Afficher cette catégorie" : context.moduleText?.checklist?.hideCompletedSectionTitle || "Réduire cette catégorie"}
aria-label={isCollapsed ? context.moduleText?.checklist?.showCompletedSectionTitle || "Afficher cette catégorie" : context.moduleText?.checklist?.hideCompletedSectionTitle || "Réduire cette catégorie"}
title={isCollapsed ? textContent.showCompletedSectionTitle || "Afficher cette catégorie" : textContent.hideCompletedSectionTitle || "Réduire cette catégorie"}
aria-label={isCollapsed ? textContent.showCompletedSectionTitle || "Afficher cette catégorie" : textContent.hideCompletedSectionTitle || "Réduire cette catégorie"}
>
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
</button>
@ -221,7 +444,20 @@ function ChecklistSection({ section, context, sections, data, save }) {
{!isCollapsed && (
<ul className="checklist-section-items">
{section.items.map((item) => (
<ChecklistItem key={item.id} item={item} context={context} items={section.items} save={saveItems} />
<ChecklistItem
key={item.id}
item={item}
section={section}
context={context}
reorder={reorder}
items={section.items}
save={saveItems}
onUpdateItem={onUpdateItem}
onMoveItemToCategory={onMoveItemToCategory}
onDeleteItem={onDeleteItem}
editing={editingItemId === item.id}
onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)}
/>
))}
</ul>
)}
@ -229,16 +465,51 @@ function ChecklistSection({ section, context, sections, data, save }) {
);
}
function ChecklistItem({ item, context, items, save }) {
function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing }) {
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
const textContent = context.moduleText?.checklist || {};
const className = [
"checklist-item",
done ? "is-complete" : "",
reorder.isItemDragging(item.id) ? "is-dragging" : "",
reorder.isItemDropTarget(item.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", item.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
const labelEdit = useInlineEdit({
value: item.label,
transform: (draft) => String(draft || "").trim() || item.label,
onCommit: (label) => onUpdateItem(item.id, (entry) => ({ ...entry, label })),
autoFocus: editing,
focusKey: item.id
});
const categoryEdit = useInlineEdit({
value: section.title,
onCommit: (title) => onMoveItemToCategory(item.id, title)
});
function updateItem(updater) {
save(items.map((entry) => entry.id === item.id ? updater(entry) : entry));
}
function toggleChecked() {
updateItem((entry) => ({
...entry,
qtyCurrent: done ? 0 : entry.qtyTarget
}));
}
return (
<li className={`checklist-item ${done ? "is-complete" : ""}`}>
<div className="checklist-item-main">
<li className={className} {...reorder.getItemProps({ itemId: item.id, groupId: section.id })}>
<div className={`checklist-item-main ${editing ? "is-editing" : ""}`}>
<button
className="checklist-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, item.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${item.label}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
{item.qtyTarget === 1 ? (
<input
type="checkbox"
@ -263,9 +534,32 @@ function ChecklistItem({ item, context, items, save }) {
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent + 1, entry.qtyTarget) }))} aria-label={context.moduleText?.checklist?.incrementLabel || "Ajouter une quantité"}>+</button>
</div>
)}
<span>{item.label}</span>
{editing ? (
<>
<input
{...labelEdit.getInputProps({
className: "checklist-label-input",
"aria-label": textContent.itemEditLabel || "Libellé"
})}
/>
<input
{...categoryEdit.getInputProps({
className: "checklist-category-input",
placeholder: textContent.sectionPlaceholder || "Catégorie",
"aria-label": textContent.categoryEditLabel || "Catégorie"
})}
/>
</>
) : (
<button className="checklist-item-summary" type="button" onClick={toggleChecked} title={item.label}>
<span>{item.label}</span>
</button>
)}
</div>
<button className="checklist-delete-button danger" onClick={() => save(items.filter((entry) => entry.id !== item.id))} aria-label={`${context.moduleText?.checklist?.deleteTitle || "Supprimer"} ${item.label}`} title={context.moduleText?.checklist?.deleteTitle || "Supprimer"}>
<button type="button" className="checklist-edit-button" onClick={onToggleEditing} aria-label={`${textContent.editTitle || "Modifier"} ${item.label}`} title={textContent.editTitle || "Modifier"}>
<Icon name="edit" />
</button>
<button className="checklist-delete-button danger" onClick={() => onDeleteItem(item.id)} aria-label={`${context.moduleText?.checklist?.deleteTitle || "Supprimer"} ${item.label}`} title={context.moduleText?.checklist?.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</li>

View file

@ -553,9 +553,10 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
);
}
function renderCategory(group) {
function renderCategory(group, index, groupCount) {
const category = group.category;
const isCollapsed = data.collapsedCategories.includes(category);
const isLastCategory = index === groupCount - 1;
const categoryClassName = [
"combos-category checklist-section is-grouped",
isCollapsed ? "is-collapsed" : "",
@ -614,10 +615,19 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
</div>
)}
</section>,
afterDropZone
showBoundaryDropZones && isLastCategory ? afterDropZone : null
];
}
const categoryCount = entries.filter((entry) => entry.type === "group").length;
let renderedCategoryIndex = -1;
function renderEntry(entry) {
if (entry.type !== "group") return renderCombo(entry.item);
renderedCategoryIndex += 1;
return renderCategory(entry.group, renderedCategoryIndex, categoryCount);
}
return (
<div className="combos-module">
{(editing || editingComboId) && (
@ -738,7 +748,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
)}
<div className="combos-category-list">
{entries.map((entry) => entry.type === "group" ? renderCategory(entry.group) : renderCombo(entry.item))}
{entries.map(renderEntry)}
</div>
</div>
);

View file

@ -1,12 +1,12 @@
// Rôle : fournit l'outil compteurs personnalisables.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
export function CountersModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
const textContent = context.moduleText?.counters || {};
const [label, setLabel] = useState("");
const counterDraft = useDraftForm({ label: "" });
const {
itemReorder,
getItemProps,
@ -28,12 +28,13 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
}
function addCounter(event) {
event.preventDefault();
const cleanLabel = label.trim();
if (!cleanLabel) return;
counterDraft.handleSubmit(event, (draft, { reset }) => {
const cleanLabel = draft.label.trim();
if (!cleanLabel) return;
save([...data.counters, { id: context.uid("counter"), label: cleanLabel, value: 0 }]);
setLabel("");
save([...data.counters, { id: context.uid("counter"), label: cleanLabel, value: 0 }]);
reset();
});
}
function updateCounter(counterId, updater) {
@ -44,7 +45,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
<>
{editing && (
<form className="inline-form counters-add-form module-add-panel" onSubmit={addCounter}>
<input name="label" placeholder={textContent.labelPlaceholder || "Nom du compteur"} value={label} onChange={(event) => setLabel(event.target.value)} />
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
)}

View file

@ -1,6 +1,7 @@
// Rôle : fournit l'outil liens avec ajout manuel et import texte.
import { useCallback, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextImportModal } from "./TextImportModal.jsx";
import { parseColonImportLines } from "./textImport.js";
@ -8,8 +9,7 @@ import { parseColonImportLines } from "./textImport.js";
export function LinksModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const textContent = context.moduleText?.links || {};
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const linkDraft = useDraftForm({ title: "", url: "" });
const [textImport, setTextImport] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [copiedId, setCopiedId] = useState("");
@ -34,20 +34,20 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
}
function addLink(event) {
event.preventDefault();
const cleanUrl = context.normalizeUrl(url);
if (!cleanUrl) return;
linkDraft.handleSubmit(event, (draft, { reset }) => {
const cleanUrl = context.normalizeUrl(draft.url);
if (!cleanUrl) return;
save([
...data.links,
{
id: context.uid("link"),
title: title.trim(),
url: cleanUrl
}
]);
setTitle("");
setUrl("");
save([
...data.links,
{
id: context.uid("link"),
title: draft.title.trim(),
url: cleanUrl
}
]);
reset();
});
}
function importLinks(event) {
@ -79,8 +79,8 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
{editing && (
<div className="module-add-panel">
<form className="inline-form links-add-form" onSubmit={addLink}>
<input name="title" placeholder={textContent.titlePlaceholder || "Nom du lien"} value={title} onChange={(event) => setTitle(event.target.value)} />
<input name="url" placeholder={textContent.urlPlaceholder || "https://..."} value={url} onChange={(event) => setUrl(event.target.value)} />
<input {...linkDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nom du lien" })} />
<input {...linkDraft.getFieldProps("url", { placeholder: textContent.urlPlaceholder || "https://..." })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<div className="text-import-actions">

View file

@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { InlineNotice } from "../../../components/AppOverlays.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
@ -222,8 +223,7 @@ function getRootTaskIdsForCategory(tasks, parentMap, category) {
export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
const textContent = context.moduleText?.taskPlanner || {};
const [title, setTitle] = useState("");
const [type, setType] = useState("daily");
const taskDraft = useDraftForm({ title: "", type: "daily" });
const [nowMs, setNowMs] = useState(() => Date.now());
const [settingsOpen, setSettingsOpen] = useState(false);
const [openDescriptions, setOpenDescriptions] = useState(() => new Set());
@ -358,25 +358,25 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
});
function addTask(event) {
event.preventDefault();
const cleanTitle = title.trim();
if (!cleanTitle) return;
save({
...data,
tasks: [
...data.tasks,
{
id: context.uid("task"),
title: cleanTitle,
description: "",
type,
checked: false,
checkedAt: 0
}
]
taskDraft.handleSubmit(event, (draft, { reset }) => {
const cleanTitle = draft.title.trim();
if (!cleanTitle) return;
save({
...data,
tasks: [
...data.tasks,
{
id: context.uid("task"),
title: cleanTitle,
description: "",
type: draft.type,
checked: false,
checkedAt: 0
}
]
});
reset();
});
setTitle("");
setType("daily");
}
function updateTask(taskId, updater) {
@ -509,8 +509,8 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
</div>
{editing && (
<form className="inline-form task-planner-add-form" onSubmit={addTask}>
<input name="title" placeholder={textContent.titlePlaceholder || "Nouvelle tâche"} value={title} onChange={(event) => setTitle(event.target.value)} />
<select value={type} onChange={(event) => setType(event.target.value)} aria-label={textContent.typeLabel || "Type"}>
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select>
<button className="primary">{textContent.addButton || "Ajouter"}</button>

View file

@ -2,6 +2,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import {
@ -24,6 +25,15 @@ const TIMER_TABS = [
];
const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
const EMPTY_TIME_PARTS = { hours: "", minutes: "", seconds: "" };
const DEFAULT_COUNTDOWN_FORM = {
label: "",
type: "duration",
duration: EMPTY_TIME_PARTS,
time: EMPTY_TIME_PARTS,
pattern: EMPTY_TIME_PARTS,
interval: EMPTY_TIME_PARTS
};
const ALERT_MODES = [
{ mode: "off", icon: "sound-mute" },
{ mode: "visible", icon: "sound-min" },
@ -73,14 +83,7 @@ export function TimerModule({ toolboxId, moduleId, context }) {
const textContent = context.moduleText?.timer || {};
const activeTab = data.activeTab;
const [nowMs, setNowMs] = useState(getNowMs);
const [countdownForm, setCountdownForm] = useState({
label: "",
type: "duration",
duration: { hours: "", minutes: "", seconds: "" },
time: { hours: "", minutes: "", seconds: "" },
pattern: { hours: "", minutes: "", seconds: "" },
interval: { hours: "", minutes: "", seconds: "" }
});
const countdownDraft = useDraftForm(DEFAULT_COUNTDOWN_FORM);
useEffect(() => {
const intervalId = window.setInterval(() => setNowMs(getNowMs()), 50);
@ -172,38 +175,35 @@ export function TimerModule({ toolboxId, moduleId, context }) {
});
}
function updateCountdownForm(patch) {
setCountdownForm((current) => ({ ...current, ...patch }));
}
function createCountdown(event) {
event.preventDefault();
const type = COUNTDOWN_TYPES.includes(countdownForm.type) ? countdownForm.type : "duration";
const label = countdownForm.label.trim() || textContent.defaultCountdownLabel || "Timer";
const now = getNowMs();
let countdown = null;
countdownDraft.handleSubmit(event, (draft, { updateValues }) => {
const type = COUNTDOWN_TYPES.includes(draft.type) ? draft.type : "duration";
const label = draft.label.trim() || textContent.defaultCountdownLabel || "Timer";
const now = getNowMs();
let countdown = null;
if (type === "duration") {
const durationMs = timePartsToDurationMs(countdownForm.duration);
if (durationMs > 0) countdown = { id: context.uid("timer"), label, type, durationMs, targetAt: now + durationMs };
}
if (type === "daily_time") {
const time = timePartsToString(countdownForm.time);
const targetAt = getDailyTargetMs(time, now);
if (time && targetAt) countdown = { id: context.uid("timer"), label, type, time, targetAt };
}
if (type === "time_pattern") {
const pattern = timePartsToString(countdownForm.pattern, { allowWildcard: true });
if (pattern && getTimePatternTargetMs(pattern, now)) countdown = { id: context.uid("timer"), label, type, pattern };
}
if (type === "interval") {
const intervalMs = timePartsToDurationMs(countdownForm.interval);
if (intervalMs > 0) countdown = { id: context.uid("timer"), label, type, intervalMs, anchorAt: now };
}
if (type === "duration") {
const durationMs = timePartsToDurationMs(draft.duration);
if (durationMs > 0) countdown = { id: context.uid("timer"), label, type, durationMs, targetAt: now + durationMs };
}
if (type === "daily_time") {
const time = timePartsToString(draft.time);
const targetAt = getDailyTargetMs(time, now);
if (time && targetAt) countdown = { id: context.uid("timer"), label, type, time, targetAt };
}
if (type === "time_pattern") {
const pattern = timePartsToString(draft.pattern, { allowWildcard: true });
if (pattern && getTimePatternTargetMs(pattern, now)) countdown = { id: context.uid("timer"), label, type, pattern };
}
if (type === "interval") {
const intervalMs = timePartsToDurationMs(draft.interval);
if (intervalMs > 0) countdown = { id: context.uid("timer"), label, type, intervalMs, anchorAt: now };
}
if (!countdown) return;
save({ ...data, countdowns: [...data.countdowns, countdown] });
setCountdownForm((current) => ({ ...current, label: "" }));
if (!countdown) return;
save({ ...data, countdowns: [...data.countdowns, countdown] });
updateValues({ label: "" });
});
}
function deleteCountdown(countdownId) {
@ -274,8 +274,8 @@ export function TimerModule({ toolboxId, moduleId, context }) {
) : (
<CountdownControls
textContent={textContent}
form={countdownForm}
onChange={updateCountdownForm}
form={countdownDraft.values}
onChange={countdownDraft.updateValues}
onSubmit={createCountdown}
/>
)}