feat(kanban): combine the columns of all lists by name in "Alle Aufgaben"; block drops into columns a task's list lacks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m37s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m37s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7860609201
commit
3e4eb9b14f
5 files changed
+124
-49
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.39.0",
|
||||
"version": "0.40.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.39.0",
|
||||
"version": "0.40.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.39.0",
|
||||
"version": "0.40.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Check,
|
||||
@@ -312,7 +312,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
return () => window.removeEventListener('mindsift:ws', handleWs);
|
||||
}, []);
|
||||
|
||||
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
|
||||
const safeTaskLists = useMemo(() => (Array.isArray(taskLists) ? taskLists : []), [taskLists]);
|
||||
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
|
||||
const isSelectedListReadOnly = currentSelectedList?.permission === 'viewer';
|
||||
const isTaskReadOnly = (task: Thought) =>
|
||||
@@ -367,10 +367,65 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}, [currentSelectedList, globalColumns]);
|
||||
|
||||
// The columns of the list a task belongs to: in "Alle Aufgaben" every task keeps its own list's columns
|
||||
const columnsFor = (task: Thought): KanbanColumn[] => {
|
||||
const own = safeTaskLists.find((l) => l.id === task.taskListId)?.columns;
|
||||
return Array.isArray(own) && own.length > 0 ? own : activeColumns;
|
||||
};
|
||||
const columnsFor = useCallback(
|
||||
(task: Thought): KanbanColumn[] => {
|
||||
const own = safeTaskLists.find((l) => l.id === task.taskListId)?.columns;
|
||||
return Array.isArray(own) && own.length > 0 ? own : activeColumns;
|
||||
},
|
||||
[safeTaskLists, activeColumns]
|
||||
);
|
||||
|
||||
// The column of its own list a task stands in; a status whose column is gone counts as the first open one
|
||||
const ownColumnOf = useCallback(
|
||||
(task: Thought): KanbanColumn | undefined => {
|
||||
const cols = columnsFor(task);
|
||||
if (task.isCompleted) return cols.find((c) => c.isDone && c.id === task.kanbanStatus) || cols.find((c) => c.isDone);
|
||||
return (
|
||||
cols.find((c) => c.id === task.kanbanStatus) ||
|
||||
cols.find((c) => c.id === 'todo') ||
|
||||
cols.find((c) => !c.isDone)
|
||||
);
|
||||
},
|
||||
[columnsFor]
|
||||
);
|
||||
|
||||
// Board columns: those of the chosen list; in "Alle Aufgaben" the columns of all lists joined by name
|
||||
// (case aside), in the order and colour of the first list that has them. Their IDs are then "name:…".
|
||||
const isCombinedBoard = selectedListId === 'all';
|
||||
const boardIdOf = useCallback(
|
||||
(col: KanbanColumn) => (isCombinedBoard ? `name:${col.name.trim().toLowerCase()}` : col.id),
|
||||
[isCombinedBoard]
|
||||
);
|
||||
const boardColumns: KanbanColumn[] = useMemo(() => {
|
||||
if (!isCombinedBoard) return activeColumns;
|
||||
const out: KanbanColumn[] = [];
|
||||
const sources = [
|
||||
...safeTaskLists.map((l) => (Array.isArray(l.columns) && l.columns.length > 0 ? l.columns : globalColumns)),
|
||||
globalColumns, // personal tasks
|
||||
];
|
||||
const idOf = (col: KanbanColumn) => `name:${col.name.trim().toLowerCase()}`;
|
||||
for (const cols of sources) {
|
||||
cols.forEach((col, i) => {
|
||||
const id = idOf(col);
|
||||
if (out.some((c) => c.id === id)) return;
|
||||
// A new column goes right before the next column of its list that is already on the board
|
||||
const next = cols.slice(i + 1).map((c) => out.findIndex((o) => o.id === idOf(c))).find((at) => at >= 0);
|
||||
out.splice(next ?? out.length, 0, { ...col, id });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [isCombinedBoard, activeColumns, safeTaskLists, globalColumns]);
|
||||
|
||||
// The task's own column behind a board column, or null if its list has no column of that name
|
||||
const ownColumnIdFor = (task: Thought | undefined, boardColId: string): string | null =>
|
||||
task ? (columnsFor(task).find((c) => boardIdOf(c) === boardColId)?.id ?? null) : null;
|
||||
const inBoardColumn = useCallback(
|
||||
(task: Thought, boardCol: KanbanColumn) => {
|
||||
const own = ownColumnOf(task);
|
||||
return !!own && boardIdOf(own) === boardCol.id;
|
||||
},
|
||||
[ownColumnOf, boardIdOf]
|
||||
);
|
||||
|
||||
const safeTasks = useMemo(() => {
|
||||
if (!Array.isArray(tasks)) return [];
|
||||
@@ -484,11 +539,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
groups.push({ key: 'tag:none', title: 'Ohne Hashtag', count: withoutTag.length, tasks: withoutTag });
|
||||
}
|
||||
} else if (groupBy === 'status') {
|
||||
activeColumns.forEach((col) => {
|
||||
const list = filteredTasks.filter((t) => {
|
||||
if (col.isDone) return t.isCompleted || t.kanbanStatus === col.id;
|
||||
return !t.isCompleted && (t.kanbanStatus === col.id || (!t.kanbanStatus && col.id === 'todo'));
|
||||
});
|
||||
boardColumns.forEach((col) => {
|
||||
const list = filteredTasks.filter((t) => inBoardColumn(t, col));
|
||||
groups.push({ key: `status:${col.id}`, title: col.name, count: list.length, tasks: list });
|
||||
});
|
||||
} else if (groupBy === 'priority') {
|
||||
@@ -511,7 +563,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
if (noDue.length > 0) groups.push({ key: 'due:none', title: 'Ohne Fälligkeitsdatum', count: noDue.length, tasks: noDue });
|
||||
}
|
||||
return groups;
|
||||
}, [filteredTasks, groupBy, activeColumns]);
|
||||
}, [filteredTasks, groupBy, boardColumns, inBoardColumn]);
|
||||
|
||||
// --- TASK ACTIONS ---
|
||||
|
||||
@@ -797,8 +849,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const title = prompt('Neue Aufgabe für diese Spalte:');
|
||||
if (!title || !title.trim()) return;
|
||||
triggerHaptic(10);
|
||||
const targetCol = activeColumns.find((c) => c.id === columnId);
|
||||
const isCompleted = targetCol ? !!targetCol.isDone : columnId === 'done';
|
||||
const targetCol = activeColumns.find((c) => boardIdOf(c) === columnId);
|
||||
if (!targetCol) return;
|
||||
columnId = targetCol.id;
|
||||
const isCompleted = !!targetCol.isDone;
|
||||
try {
|
||||
const created = await api.captureThought(title.trim(), 'normal', 'manual', undefined, {
|
||||
target: 'task',
|
||||
@@ -882,7 +936,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const updatedSourceTask: Thought = {
|
||||
...sourceTask,
|
||||
kanbanStatus: newKanbanCol || targetTask.kanbanStatus || sourceTask.kanbanStatus,
|
||||
isCompleted: newKanbanCol ? newKanbanCol === 'done' : targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted,
|
||||
isCompleted: newKanbanCol
|
||||
? !!columnsFor(sourceTask).find((c) => c.id === newKanbanCol)?.isDone || newKanbanCol === 'done'
|
||||
: targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted,
|
||||
};
|
||||
|
||||
const newTasks = [...remainingTasks];
|
||||
@@ -941,8 +997,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
);
|
||||
|
||||
const handleKanbanColDragOver = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
|
||||
e.preventDefault();
|
||||
if (!draggingTaskId) return;
|
||||
// A column the card's list does not have takes nothing (no preventDefault = no drop allowed)
|
||||
if (!ownColumnIdFor(tasks.find((t) => t.id === draggingTaskId), colId)) return;
|
||||
e.preventDefault();
|
||||
if (dragOverKanbanCol !== colId) setDragOverKanbanCol(colId);
|
||||
// Slot = number of cards (without the dragged one) whose middle lies above the pointer
|
||||
const cards = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[data-kanban-task]')).filter(
|
||||
@@ -974,16 +1032,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const slot = kanbanSlot;
|
||||
resetKanbanDrag();
|
||||
if (!sourceId) return;
|
||||
const ownColId = ownColumnIdFor(tasks.find((t) => t.id === sourceId), colId);
|
||||
if (!ownColId) return;
|
||||
// Set with the move in one render: the card shows up marked in its new place, not a moment later
|
||||
setJustDroppedId(sourceId);
|
||||
window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900);
|
||||
const others = colTasks.filter((t) => t.id !== sourceId);
|
||||
if (others.length === 0 || !slot || slot.col !== colId) {
|
||||
await moveTaskToColumn(sourceId, colId);
|
||||
await moveTaskToColumn(sourceId, ownColId);
|
||||
} else if (slot.index < others.length) {
|
||||
await moveTaskNextTo(sourceId, others[slot.index], 'before', colId);
|
||||
await moveTaskNextTo(sourceId, others[slot.index], 'before', ownColId);
|
||||
} else {
|
||||
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', colId);
|
||||
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', ownColId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1009,7 +1069,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const sourceTask = tasks.find((t) => t.id === sourceId);
|
||||
if (!sourceTask) return;
|
||||
|
||||
const isDone = colId === 'done';
|
||||
const targetCol = columnsFor(sourceTask).find((c) => c.id === colId);
|
||||
const isDone = targetCol ? !!targetCol.isDone : colId === 'done';
|
||||
const updatedSourceTask: Thought = {
|
||||
...sourceTask,
|
||||
kanbanStatus: colId,
|
||||
@@ -1053,14 +1114,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
enabled: !canDragTasks && viewMode === 'kanban',
|
||||
onStart: (taskId) => setDraggingTaskId(taskId),
|
||||
onHover: (colId, target) => {
|
||||
setDragOverKanbanCol(colId);
|
||||
setDragOverTaskTarget(target);
|
||||
const allowed = !!colId && !!ownColumnIdFor(tasks.find((t) => t.id === draggingTaskId), colId);
|
||||
setDragOverKanbanCol(allowed ? colId : null);
|
||||
setDragOverTaskTarget(allowed ? target : null);
|
||||
},
|
||||
onDrop: (taskId, colId, target) => {
|
||||
clearDragState();
|
||||
const ownColId = colId ? ownColumnIdFor(tasks.find((t) => t.id === taskId), colId) : null;
|
||||
if (!ownColId) return;
|
||||
const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined;
|
||||
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, colId ?? undefined);
|
||||
else if (colId) void moveTaskToColumn(taskId, colId);
|
||||
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, ownColId);
|
||||
else void moveTaskToColumn(taskId, ownColId);
|
||||
},
|
||||
onCancel: clearDragState,
|
||||
});
|
||||
@@ -2414,17 +2478,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
|
||||
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
|
||||
}`}
|
||||
style={kanbanBoardStyle(isKanbanWide, activeColumns.length)}
|
||||
style={kanbanBoardStyle(isKanbanWide, boardColumns.length)}
|
||||
>
|
||||
{activeColumns.map((col, colIdx) => {
|
||||
const colTasks = kanbanTasks.filter((t) => {
|
||||
if (col.isDone) {
|
||||
return t.isCompleted || t.kanbanStatus === col.id;
|
||||
}
|
||||
if (t.isCompleted) return false;
|
||||
if (t.kanbanStatus) return t.kanbanStatus === col.id;
|
||||
return col.id === 'todo';
|
||||
});
|
||||
{boardColumns.map((col) => {
|
||||
const colTasks = kanbanTasks.filter((t) => inBoardColumn(t, col));
|
||||
// While dragging: a column the card's list does not have is greyed out and takes nothing
|
||||
const blockedForDrag =
|
||||
!!draggingTaskId && !ownColumnIdFor(tasks.find((t) => t.id === draggingTaskId), col.id);
|
||||
|
||||
// Cards that take part in the slot count (the dragged one has left its place)
|
||||
const visibleIds = colTasks.filter((t) => t.id !== kanbanHiddenId).map((t) => t.id);
|
||||
@@ -2439,7 +2499,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
data-kanban-col={col.id}
|
||||
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
|
||||
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
|
||||
}`}
|
||||
} ${blockedForDrag ? 'opacity-40' : ''}`}
|
||||
title={blockedForDrag ? 'Diese Spalte gibt es in der Liste der Aufgabe nicht' : undefined}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="px-3 py-2 bg-panel border-b border-border flex items-center justify-between">
|
||||
@@ -2456,6 +2517,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
const readOnly = isTaskReadOnly(task);
|
||||
const visibleIdx = visibleIds.indexOf(task.id);
|
||||
const ownCols = columnsFor(task);
|
||||
const ownIdx = ownCols.findIndex((c) => c.id === ownColumnOf(task)?.id);
|
||||
const prevCol = ownIdx > 0 ? ownCols[ownIdx - 1] : undefined;
|
||||
const nextCol = ownIdx >= 0 ? ownCols[ownIdx + 1] : undefined;
|
||||
|
||||
return (
|
||||
<React.Fragment key={task.id}>
|
||||
@@ -2606,23 +2671,23 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={colIdx === 0}
|
||||
onClick={() => handleSetKanbanStatus(task, activeColumns[colIdx - 1].id)}
|
||||
disabled={!prevCol}
|
||||
onClick={() => prevCol && handleSetKanbanStatus(task, prevCol.id)}
|
||||
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
|
||||
title={colIdx > 0 ? `Nach "${activeColumns[colIdx - 1].name}"` : ''}
|
||||
title={prevCol ? `Nach "${prevCol.name}"` : ''}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">{colIdx > 0 ? activeColumns[colIdx - 1].name : ''}</span>
|
||||
<span className="hidden sm:inline">{prevCol?.name ?? ''}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={colIdx === activeColumns.length - 1}
|
||||
onClick={() => handleSetKanbanStatus(task, activeColumns[colIdx + 1].id)}
|
||||
disabled={!nextCol}
|
||||
onClick={() => nextCol && handleSetKanbanStatus(task, nextCol.id)}
|
||||
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
|
||||
title={colIdx < activeColumns.length - 1 ? `Nach "${activeColumns[colIdx + 1].name}"` : ''}
|
||||
title={nextCol ? `Nach "${nextCol.name}"` : ''}
|
||||
>
|
||||
<span className="hidden sm:inline">{colIdx < activeColumns.length - 1 ? activeColumns[colIdx + 1].name : ''}</span>
|
||||
<span className="hidden sm:inline">{nextCol?.name ?? ''}</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -2643,7 +2708,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Quick Add Task to Column */}
|
||||
{!isSelectedListReadOnly && (
|
||||
{!isSelectedListReadOnly && activeColumns.some((c) => boardIdOf(c) === col.id) && (
|
||||
<div className="p-2 border-t border-border bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in new issue
Block a user