diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 8b13fdd..155b6bf 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -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/); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 002f826..3d02de2 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -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({ diff --git a/website/src/features/toolboxes/modules/ChecklistModule.jsx b/website/src/features/toolboxes/modules/ChecklistModule.jsx index d71bb58..0e57b60 100644 --- a/website/src/features/toolboxes/modules/ChecklistModule.jsx +++ b/website/src/features/toolboxes/modules/ChecklistModule.jsx @@ -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 ( -
  • - {showTitle && ( -
    - -

    - -

    -
    - {section.items.filter((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget).length} / {section.items.length} - -
    -
    - )} - {!isCollapsed && ( - - )} -
  • - ); -} - -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 ( -
  • -
    - - {item.qtyTarget === 1 ? ( - updateItem((entry) => ({ ...entry, qtyTarget: 1, qtyCurrent: event.target.checked ? 1 : 0 }))} - aria-label={`Terminer ${item.label}`} - /> - ) : ( -
    - - - -
    - )} - {editing ? ( - <> - - - - ) : ( - - )} -
    - - -
  • - ); -} diff --git a/website/src/features/toolboxes/modules/ChecklistParts.jsx b/website/src/features/toolboxes/modules/ChecklistParts.jsx new file mode 100644 index 0000000..09a26f8 --- /dev/null +++ b/website/src/features/toolboxes/modules/ChecklistParts.jsx @@ -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 ( +
  • + {showTitle && ( +
    + +

    + +

    +
    + {section.items.filter((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget).length} / {section.items.length} + +
    +
    + )} + {!isCollapsed && ( + + )} +
  • + ); +} + +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 ( +
  • +
    + + {item.qtyTarget === 1 ? ( + updateItem((entry) => ({ ...entry, qtyTarget: 1, qtyCurrent: event.target.checked ? 1 : 0 }))} + aria-label={`Terminer ${item.label}`} + /> + ) : ( +
    + + + +
    + )} + {editing ? ( + <> + + + + ) : ( + + )} +
    + + +
  • + ); +} diff --git a/website/src/features/toolboxes/modules/checklistUtils.js b/website/src/features/toolboxes/modules/checklistUtils.js new file mode 100644 index 0000000..986e452 --- /dev/null +++ b/website/src/features/toolboxes/modules/checklistUtils.js @@ -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; +}