Refactor checklist module structure
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
This commit is contained in:
parent
f2c88ee891
commit
ed710b3399
5 changed files with 396 additions and 352 deletions
|
|
@ -178,6 +178,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
const equipmentPlannerSummary = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerSummary.jsx", "utf8");
|
||||
const equipmentPlannerOptions = await readFile("website/src/features/toolboxes/modules/equipmentPlannerOptions.js", "utf8");
|
||||
const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8");
|
||||
const checklistParts = await readFile("website/src/features/toolboxes/modules/ChecklistParts.jsx", "utf8");
|
||||
const checklistUtils = await readFile("website/src/features/toolboxes/modules/checklistUtils.js", "utf8");
|
||||
const checklistBundle = `${checklistModule}\n${checklistParts}\n${checklistUtils}`;
|
||||
const imagesModule = await readFile("website/src/features/toolboxes/modules/ImagesModule.jsx", "utf8");
|
||||
const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8");
|
||||
const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8");
|
||||
|
|
@ -437,21 +440,21 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
|||
assert.match(checklistModule, /export function ChecklistModule/);
|
||||
assert.match(checklistModule, /TextExchangeActions/);
|
||||
assert.match(checklistModule, /editing &&/);
|
||||
assert.match(checklistModule, /ChecklistItem/);
|
||||
assert.match(checklistModule, /ChecklistSection/);
|
||||
assert.match(checklistModule, /chevron-down/);
|
||||
assert.match(checklistModule, /chevron-up/);
|
||||
assert.match(checklistModule, /checklist-qty-current/);
|
||||
assert.match(checklistModule, /checklist-delete-button danger/);
|
||||
assert.match(checklistParts, /ChecklistItem/);
|
||||
assert.match(checklistParts, /export function ChecklistSection/);
|
||||
assert.match(checklistParts, /chevron-down/);
|
||||
assert.match(checklistParts, /chevron-up/);
|
||||
assert.match(checklistParts, /checklist-qty-current/);
|
||||
assert.match(checklistParts, /checklist-delete-button danger/);
|
||||
assert.match(checklistModule, /useGroupedReorder/);
|
||||
assert.match(checklistModule, /getItemProps/);
|
||||
assert.match(checklistModule, /getGroupProps/);
|
||||
assert.match(checklistBundle, /getItemProps/);
|
||||
assert.match(checklistParts, /getGroupProps/);
|
||||
assert.match(checklistModule, /getGroupBoundaryProps/);
|
||||
assert.match(checklistModule, /checklist-category-boundary-drop-zone/);
|
||||
assert.match(checklistModule, /itemReorder\.startDrag/);
|
||||
assert.match(checklistModule, /groupReorder\.startDrag/);
|
||||
assert.match(checklistModule, /useInlineEdit/);
|
||||
assert.match(checklistModule, /moveChecklistItemToCategory/);
|
||||
assert.match(checklistParts, /itemReorder\.startDrag/);
|
||||
assert.match(checklistParts, /groupReorder\.startDrag/);
|
||||
assert.match(checklistParts, /useInlineEdit/);
|
||||
assert.match(checklistUtils, /export function moveChecklistItemToCategory/);
|
||||
assert.match(textImportModal, /createPortal/);
|
||||
assert.match(textImportModal, /lockBodyScroll/);
|
||||
assert.match(textImportModal, /has-error/);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { getEffectiveGoalTreeProgressMode, getVisibleGoalTreeEdges } from "../we
|
|||
import { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js";
|
||||
import { createDefaultDraft, getDateSpanDays, getDraftSelection, normalizeDraftAlertOffsets, normalizeDraftMonthlyRepeatBy } from "../website/src/features/toolboxes/modules/calendarDraft.js";
|
||||
import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.js";
|
||||
import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js";
|
||||
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
|
||||
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeModuleOrder, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
|
||||
|
|
@ -789,6 +790,27 @@ test("checklist data drops empty titled sections", () => {
|
|||
assert.deepEqual(compact.sections.map((section) => section.id), ["filled"]);
|
||||
});
|
||||
|
||||
test("checklist helpers merge categories and move items deterministically", () => {
|
||||
const context = { uid: (prefix) => `${prefix}-new` };
|
||||
const sections = [
|
||||
{ id: "s1", title: "Alpha", items: [{ id: "i1", label: "One" }, { id: "i2", label: "Two" }] },
|
||||
{ id: "s2", title: "Beta", items: [{ id: "i3", label: "Three" }] }
|
||||
];
|
||||
assert.deepEqual(appendChecklistItems(sections, "alpha", [{ id: "i4", label: "Four" }], context)[0].items.map((item) => item.id), ["i1", "i2", "i4"]);
|
||||
assert.deepEqual(renameChecklistSection(sections, "s1", "Beta").map((section) => [section.title, section.items.map((item) => item.id)]), [
|
||||
["Beta", ["i3", "i1", "i2"]]
|
||||
]);
|
||||
assert.deepEqual(moveChecklistItemToCategory(sections, "i2", "Gamma", context).map((section) => [section.title, section.items.map((item) => item.id)]), [
|
||||
["Alpha", ["i1"]],
|
||||
["Beta", ["i3"]],
|
||||
["Gamma", ["i2"]]
|
||||
]);
|
||||
assert.deepEqual(moveChecklistItem(sections, { sourceId: "i1", targetType: "item", targetId: "i3", placement: "after" }, context).map((section) => [section.title, section.items.map((item) => item.id)]), [
|
||||
["Alpha", ["i2"]],
|
||||
["Beta", ["i3", "i1"]]
|
||||
]);
|
||||
});
|
||||
|
||||
test("combos storage normalizes devices, steps and compact export ids", () => {
|
||||
const longText = "x".repeat(120);
|
||||
const normalized = normalizeCombosData({
|
||||
|
|
|
|||
|
|
@ -2,8 +2,18 @@
|
|||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { ChecklistSection } from "./ChecklistParts.jsx";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import {
|
||||
appendChecklistItems,
|
||||
getChecklistReorderItems,
|
||||
isSectionComplete,
|
||||
keepChecklistSection,
|
||||
moveChecklistItem,
|
||||
moveChecklistItemToCategory,
|
||||
renameChecklistSection,
|
||||
updateChecklistItem
|
||||
} from "./checklistUtils.js";
|
||||
|
||||
export function ChecklistModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
|
||||
|
|
@ -61,21 +71,12 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
|
|||
context.setModuleData(toolboxId, moduleId, { ...data, ...settings, sections });
|
||||
}
|
||||
|
||||
function appendItems(sections, title, items) {
|
||||
const cleanTitle = title.trim();
|
||||
const existingIndex = sections.findIndex((section) => section.title.toLowerCase() === cleanTitle.toLowerCase());
|
||||
if (existingIndex >= 0) {
|
||||
return sections.map((section, index) => index === existingIndex ? { ...section, items: [...section.items, ...items] } : section);
|
||||
}
|
||||
return [...sections, { id: context.uid("section"), title: cleanTitle, items }];
|
||||
}
|
||||
|
||||
function addItem(event) {
|
||||
event.preventDefault();
|
||||
const cleanLabel = label.trim();
|
||||
if (!cleanLabel) return;
|
||||
const nextItem = { id: context.uid("item"), label: cleanLabel, qtyTarget: Math.max(1, Number(qty) || 1), qtyCurrent: 0 };
|
||||
save(appendItems(data.sections, sectionTitle, [nextItem]));
|
||||
save(appendChecklistItems(data.sections, sectionTitle, [nextItem], context));
|
||||
setLabel("");
|
||||
setQty(1);
|
||||
}
|
||||
|
|
@ -183,332 +184,3 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isSectionComplete(section, context) {
|
||||
return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget);
|
||||
}
|
||||
|
||||
function getSectionHideWhenComplete(section, globalHide) {
|
||||
return typeof section.hideWhenComplete === "boolean" ? section.hideWhenComplete : globalHide;
|
||||
}
|
||||
|
||||
function keepChecklistSection(section) {
|
||||
return section.items.length;
|
||||
}
|
||||
|
||||
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) {
|
||||
onUpdateSection(section.id, (entry) => ({ ...entry, items }));
|
||||
}
|
||||
|
||||
function toggleSectionCollapsed() {
|
||||
const nextCollapsed = !isCollapsed;
|
||||
const nextSections = data.sections.map((entry) => {
|
||||
if (entry.id === section.id) {
|
||||
const nextSection = { ...entry };
|
||||
if (nextCollapsed) {
|
||||
if (sectionComplete) {
|
||||
nextSection.hideWhenComplete = true;
|
||||
delete nextSection.collapsed;
|
||||
} else {
|
||||
nextSection.collapsed = true;
|
||||
}
|
||||
} else {
|
||||
delete nextSection.collapsed;
|
||||
if (sectionComplete) nextSection.hideWhenComplete = false;
|
||||
}
|
||||
return nextSection;
|
||||
}
|
||||
if (data.hideCompletedSections && !nextCollapsed && entry.title && isSectionComplete(entry, context)) return { ...entry, hideWhenComplete: true };
|
||||
return entry;
|
||||
});
|
||||
const nextCompletedSections = nextSections.filter((entry) => entry.title && isSectionComplete(entry, context));
|
||||
const allHidden = nextCompletedSections.length > 0 && nextCompletedSections.every((entry) => getSectionHideWhenComplete(entry, false));
|
||||
onSaveSections(nextSections, { hideCompletedSections: allHidden });
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={className} {...reorder.getGroupProps({ groupId: section.id })}>
|
||||
{showTitle && (
|
||||
<div className="checklist-section-header">
|
||||
<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 ? 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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<ul className="checklist-section-items">
|
||||
{section.items.map((item) => (
|
||||
<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>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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={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"
|
||||
checked={done}
|
||||
onChange={(event) => updateItem((entry) => ({ ...entry, qtyTarget: 1, qtyCurrent: event.target.checked ? 1 : 0 }))}
|
||||
aria-label={`Terminer ${item.label}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="checklist-qty-controls" aria-label={`Quantité ${item.label}`}>
|
||||
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent - 1, entry.qtyTarget) }))} aria-label={context.moduleText?.checklist?.decrementLabel || "Retirer une quantité"}>-</button>
|
||||
<label className="checklist-qty-current">
|
||||
<input
|
||||
type="number"
|
||||
max={item.qtyTarget}
|
||||
value={context.clampQty(item.qtyCurrent, item.qtyTarget)}
|
||||
style={{ "--qty-current-digits": String(context.clampQty(item.qtyCurrent, item.qtyTarget)).length }}
|
||||
onChange={(event) => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(event.target.value, entry.qtyTarget) }))}
|
||||
aria-label={`${context.moduleText?.checklist?.currentQuantityLabel || "Quantité actuelle"} ${item.label}`}
|
||||
/>
|
||||
<span>/ {item.qtyTarget}</span>
|
||||
</label>
|
||||
<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>
|
||||
)}
|
||||
{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 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
225
website/src/features/toolboxes/modules/ChecklistParts.jsx
Normal file
225
website/src/features/toolboxes/modules/ChecklistParts.jsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// Rôle : rend les sections et items de checklist avec édition inline et quantités.
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import {
|
||||
getSectionHideWhenComplete,
|
||||
isSectionComplete,
|
||||
keepChecklistSection
|
||||
} from "./checklistUtils.js";
|
||||
|
||||
export 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) {
|
||||
onUpdateSection(section.id, (entry) => ({ ...entry, items }));
|
||||
}
|
||||
|
||||
function toggleSectionCollapsed() {
|
||||
const nextCollapsed = !isCollapsed;
|
||||
const nextSections = data.sections.map((entry) => {
|
||||
if (entry.id === section.id) {
|
||||
const nextSection = { ...entry };
|
||||
if (nextCollapsed) {
|
||||
if (sectionComplete) {
|
||||
nextSection.hideWhenComplete = true;
|
||||
delete nextSection.collapsed;
|
||||
} else {
|
||||
nextSection.collapsed = true;
|
||||
}
|
||||
} else {
|
||||
delete nextSection.collapsed;
|
||||
if (sectionComplete) nextSection.hideWhenComplete = false;
|
||||
}
|
||||
return nextSection;
|
||||
}
|
||||
if (data.hideCompletedSections && !nextCollapsed && entry.title && isSectionComplete(entry, context)) return { ...entry, hideWhenComplete: true };
|
||||
return entry;
|
||||
});
|
||||
const nextCompletedSections = nextSections.filter((entry) => entry.title && isSectionComplete(entry, context));
|
||||
const allHidden = nextCompletedSections.length > 0 && nextCompletedSections.every((entry) => getSectionHideWhenComplete(entry, false));
|
||||
onSaveSections(nextSections.filter(keepChecklistSection), { hideCompletedSections: allHidden });
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={className} {...reorder.getGroupProps({ groupId: section.id })}>
|
||||
{showTitle && (
|
||||
<div className="checklist-section-header">
|
||||
<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 ? 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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<ul className="checklist-section-items">
|
||||
{section.items.map((item) => (
|
||||
<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>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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={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"
|
||||
checked={done}
|
||||
onChange={(event) => updateItem((entry) => ({ ...entry, qtyTarget: 1, qtyCurrent: event.target.checked ? 1 : 0 }))}
|
||||
aria-label={`Terminer ${item.label}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="checklist-qty-controls" aria-label={`Quantité ${item.label}`}>
|
||||
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent - 1, entry.qtyTarget) }))} aria-label={textContent.decrementLabel || "Retirer une quantité"}>-</button>
|
||||
<label className="checklist-qty-current">
|
||||
<input
|
||||
type="number"
|
||||
max={item.qtyTarget}
|
||||
value={context.clampQty(item.qtyCurrent, item.qtyTarget)}
|
||||
style={{ "--qty-current-digits": String(context.clampQty(item.qtyCurrent, item.qtyTarget)).length }}
|
||||
onChange={(event) => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(event.target.value, entry.qtyTarget) }))}
|
||||
aria-label={`${textContent.currentQuantityLabel || "Quantité actuelle"} ${item.label}`}
|
||||
/>
|
||||
<span>/ {item.qtyTarget}</span>
|
||||
</label>
|
||||
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent + 1, entry.qtyTarget) }))} aria-label={textContent.incrementLabel || "Ajouter une quantité"}>+</button>
|
||||
</div>
|
||||
)}
|
||||
{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 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={`${textContent.deleteTitle || "Supprimer"} ${item.label}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
122
website/src/features/toolboxes/modules/checklistUtils.js
Normal file
122
website/src/features/toolboxes/modules/checklistUtils.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Rôle : regroupe les helpers purs de sections, items et déplacement de checklist.
|
||||
export function isSectionComplete(section, context) {
|
||||
return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget);
|
||||
}
|
||||
|
||||
export function getSectionHideWhenComplete(section, globalHide) {
|
||||
return typeof section.hideWhenComplete === "boolean" ? section.hideWhenComplete : globalHide;
|
||||
}
|
||||
|
||||
export function keepChecklistSection(section) {
|
||||
return section.items.length;
|
||||
}
|
||||
|
||||
export function getChecklistReorderItems(sections) {
|
||||
return sections.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })));
|
||||
}
|
||||
|
||||
export function appendChecklistItems(sections, title, items, context) {
|
||||
const cleanTitle = title.trim();
|
||||
const existingIndex = sections.findIndex((section) => section.title.toLowerCase() === cleanTitle.toLowerCase());
|
||||
if (existingIndex >= 0) {
|
||||
return sections.map((section, index) => index === existingIndex ? { ...section, items: [...section.items, ...items] } : section);
|
||||
}
|
||||
return [...sections, { id: context.uid("section"), title: cleanTitle, items }];
|
||||
}
|
||||
|
||||
export function findChecklistSectionByItemId(sections, itemId) {
|
||||
return sections.find((section) => section.items.some((item) => item.id === itemId));
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export 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] }];
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue