From ee579af2ecc05167b21f7380785ef1b6329695c8 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Wed, 29 Jul 2026 08:54:18 +0200 Subject: [PATCH] allow drag in and out of categorie --- docs/STORAGE_SCHEMA.md | 2 +- tests/helpers/data-validation.mjs | 1 + website/public/data/site.json | 1 + .../toolboxes/modules/TaskPlannerModule.jsx | 78 +++++++++++++++---- website/src/styles/_toolboxes.scss | 25 ++++++ 5 files changed, 91 insertions(+), 16 deletions(-) diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 83a5261..7d4522a 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -319,7 +319,7 @@ Notes : - une tâche ne conserve qu'un seul parent. - `prerequisite: true` marque aussi ce parent comme pré requis. - Les pré requis sont non bloquants : ils affichent un avertissement si le parent requis n'est pas effectué. -- En tri manuel, une tâche catégorisée ne peut être réordonnée qu'avec des tâches du même parent et de la même catégorie. +- Le drag & drop conserve l'ordre manuel, et peut aussi ajouter une tâche racine à une catégorie existante ou la sortir vers les tâches sans catégorie. Stockage compact : diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index bd002c7..d934b0a 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -218,6 +218,7 @@ export function validateSiteContent(site) { "toolboxes.modules.taskPlanner.categoryLabel", "toolboxes.modules.taskPlanner.categoryPlaceholder", "toolboxes.modules.taskPlanner.categoryReorderTitle", + "toolboxes.modules.taskPlanner.uncategorizedDropTitle", "toolboxes.modules.taskPlanner.showCategoryTitle", "toolboxes.modules.taskPlanner.hideCategoryTitle", "toolboxes.modules.taskPlanner.taskWeeklyResetDayLabel", diff --git a/website/public/data/site.json b/website/public/data/site.json index 82a2558..5aeab3c 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -352,6 +352,7 @@ "categoryLabel": "Catégorie", "categoryPlaceholder": "Nom de catégorie", "categoryReorderTitle": "Déplacer la catégorie", + "uncategorizedDropTitle": "Sans catégorie", "showCategoryTitle": "Afficher cette catégorie", "hideCategoryTitle": "Réduire cette catégorie", "taskWeeklyResetDayLabel": "Jour hebdo", diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx index be0cec9..2516c35 100644 --- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx +++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx @@ -5,6 +5,7 @@ import { InlineNotice } from "../../../components/AppOverlays.jsx"; import { usePointerReorder } from "../../../hooks/usePointerReorder.js"; const TASK_TYPES = ["daily", "weekly", "unique"]; +const UNCATEGORIZED_DROP_ID = "task-planner:uncategorized"; const WEEK_DAYS = [ { value: 1, label: "Lundi" }, { value: 2, label: "Mardi" }, @@ -252,6 +253,10 @@ function isCategorySectionId(id) { return String(id || "").startsWith("category\n"); } +function isUncategorizedDropId(id) { + return id === UNCATEGORIZED_DROP_ID; +} + function getRootTaskIdsForCategory(tasks, parentMap, category) { return tasks .filter((task) => !parentMap.get(task.id) && getTaskCategory(task) === category) @@ -275,29 +280,58 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { dropTarget, startDrag } = usePointerReorder({ - targetSelector: ".task-planner-item, .task-planner-category-section", - getTargetId: (target) => target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), + targetSelector: ".task-planner-item, .task-planner-category-section, .task-planner-uncategorized-drop-zone", + getTargetId: (target) => target.dataset.dropId || target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), canDropOn: (target, draggingTaskId) => { const draggingTask = data.tasks.find((task) => task.id === draggingTaskId); if (!draggingTask) return false; - if (target.classList.contains("task-planner-category-section")) { - return !parentMap.get(draggingTaskId) && !getTaskCategory(draggingTask) && !target.dataset.parentId && Boolean(target.dataset.category); + const draggingParentId = parentMap.get(draggingTaskId) || ""; + const draggingCategory = getTaskCategoryDataset(draggingTask, data.tasks, parentMap); + if (target.classList.contains("task-planner-uncategorized-drop-zone")) { + return !draggingParentId && Boolean(draggingCategory); } - return target.dataset.parentId === (parentMap.get(draggingTaskId) || "") - && ( - target.dataset.category === getTaskCategoryDataset(draggingTask, data.tasks, parentMap) - || (!parentMap.get(draggingTaskId) && !getTaskCategory(draggingTask) && Boolean(target.dataset.category)) - ); + if (target.classList.contains("task-planner-category-section")) { + return !draggingParentId && !target.dataset.parentId && Boolean(target.dataset.category) && target.dataset.category !== draggingCategory; + } + if (target.dataset.parentId !== draggingParentId) return false; + if (target.dataset.category === draggingCategory) return true; + return !draggingParentId; }, onMove: (fromTaskId, toId, placement) => { - if (isCategorySectionId(toId)) { - const targetCategory = parseCategorySectionId(toId).category; - const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, targetCategory); + const fromTask = data.tasks.find((task) => task.id === fromTaskId); + if (!fromTask) return; + function setRootTaskCategory(tasks, category) { + return tasks.map((task) => { + if (task.id !== fromTaskId) return task; + const nextTask = { ...task }; + if (category) nextTask.category = category; + else delete nextTask.category; + return nextTask; + }); + } + function moveToCategory(category) { + const nextTasks = setRootTaskCategory(data.tasks, category); + const targetIds = category + ? getRootTaskIdsForCategory(data.tasks, parentMap, category).filter((taskId) => taskId !== fromTaskId) + : data.tasks.filter((task) => !parentMap.get(task.id) && !getTaskCategory(task) && task.id !== fromTaskId).map((task) => task.id); const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, placement); - if (boundaryTaskId) save({ ...data, tasks: moveTask(data.tasks, fromTaskId, boundaryTaskId, placement) }); + save({ ...data, tasks: boundaryTaskId ? moveTask(nextTasks, fromTaskId, boundaryTaskId, placement) : nextTasks }); + } + if (isUncategorizedDropId(toId)) { + moveToCategory(""); return; } - save({ ...data, tasks: moveTask(data.tasks, fromTaskId, toId, placement) }); + if (isCategorySectionId(toId)) { + const targetCategory = parseCategorySectionId(toId).category; + moveToCategory(targetCategory); + return; + } + const targetTask = data.tasks.find((task) => task.id === toId); + const targetCategory = targetTask ? getTaskCategoryDataset(targetTask, data.tasks, parentMap) : ""; + const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap); + const shouldChangeRootCategory = !parentMap.get(fromTaskId) && targetTask && !parentMap.get(toId) && targetCategory !== sourceCategory; + const tasks = shouldChangeRootCategory ? setRootTaskCategory(data.tasks, targetCategory) : data.tasks; + save({ ...data, tasks: moveTask(tasks, fromTaskId, toId, placement) }); } }); const { @@ -608,6 +642,8 @@ function TaskPlannerBranch({ const children = [...groupedTasks.uncategorized, ...groupedTasks.categories.flatMap((group) => group.tasks)].filter((task) => !visited.has(task.id)); if (!children.length) return null; const nextVisited = new Set([...visited, ...children.map((task) => task.id)]); + const draggingTask = draggingId ? getTaskById(tasks, draggingId) : null; + const showUncategorizedDropZone = !parentId && draggingTask && !parentMap.get(draggingTask.id) && Boolean(getTaskCategoryDataset(draggingTask, tasks, parentMap)); function hasVisibleDescendants(taskId, seen = new Set()) { if (seen.has(taskId)) return false; @@ -739,10 +775,22 @@ function TaskPlannerBranch({ ); } - return groupedTasks.entries.map((entry) => { + const entries = groupedTasks.entries.map((entry) => { if (entry.type === "task") return visited.has(entry.task.id) ? null : renderTask(entry.task); return renderCategory(entry.group); }).filter(Boolean); + if (!showUncategorizedDropZone) return entries; + const dropZoneClassName = [ + "task-planner-uncategorized-drop-zone", + dropTarget.id === UNCATEGORIZED_DROP_ID ? "is-drop-target" : "", + dropTarget.id === UNCATEGORIZED_DROP_ID && dropTarget.placement === "after" ? "drop-after" : "" + ].filter(Boolean).join(" "); + return [ +
  • + {textContent.uncategorizedDropTitle || "Sans catégorie"} +
  • , + ...entries + ]; } function TaskPlannerItem({ diff --git a/website/src/styles/_toolboxes.scss b/website/src/styles/_toolboxes.scss index c35c2ae..84a4129 100644 --- a/website/src/styles/_toolboxes.scss +++ b/website/src/styles/_toolboxes.scss @@ -1369,6 +1369,31 @@ textarea:focus { text-align: center; } +.task-planner-uncategorized-drop-zone { + padding: 9px 12px; + border: 1px dashed rgba(165, 180, 252, 0.22); + border-radius: var(--radius-md); + background: rgba(15, 23, 42, 0.26); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 900; + text-align: center; + text-transform: uppercase; +} + +.task-planner-uncategorized-drop-zone.is-drop-target { + border-color: rgba(246, 196, 83, 0.72); + background: + linear-gradient(135deg, rgba(246, 196, 83, 0.08), transparent 56%), + rgba(15, 23, 42, 0.34); + color: var(--color-accent-gold); + box-shadow: inset 0 3px 0 rgba(246, 196, 83, 0.8); +} + +.task-planner-uncategorized-drop-zone.is-drop-target.drop-after { + box-shadow: inset 0 -3px 0 rgba(246, 196, 83, 0.8); +} + .task-planner-category-section { display: grid; gap: 8px;