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 ( -