Got drag and drop working

This commit is contained in:
Akaswan 2025-07-05 22:43:41 -05:00
parent 3b836f4b76
commit ffcd1385c0
6 changed files with 369 additions and 201 deletions

58
package-lock.json generated
View file

@ -9,6 +9,8 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@headlessui/react": "^2.2.2",
"date-fns": "^4.1.0",
"lucide-react": "^0.469.0",
@ -50,6 +52,59 @@
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.3.tgz",
@ -2379,8 +2434,7 @@
"node_modules/tslib": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"dev": true
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
},
"node_modules/tsutils": {
"version": "3.21.0",

View file

@ -24,6 +24,8 @@
"typescript": "4.7.4"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@headlessui/react": "^2.2.2",
"date-fns": "^4.1.0",
"lucide-react": "^0.469.0",

View file

@ -1,3 +1,4 @@
/* eslint-disable no-mixed-spaces-and-tabs */
import { useEffect, useState } from "react";
import { useTableContext } from "../views/TableView";
import Table from "./Table";
@ -44,19 +45,30 @@ const App: React.FC = () => {
const [dates, setDates] = useState(getWeekDates(new Date()));
const [data, setData] = useState(() => tableContext!.loadData());
const [data, setData] = useState(() => tableContext?.loadData());
const [taskStatuses, setTaskStatuses] = useState(getTaskStatuses());
const [taskStatuses] = useState(getTaskStatuses());
const [projects, setProjects] = useState(
() => tableContext!.loadData().projects as Project[]
);
const [nextProjectId, setNextProjectId] = useState(
() => tableContext!.loadData().nextProjectId as number
);
const [nextTaskId, setNextTaskId] = useState(
() => tableContext!.loadData().nextTaskId as number
);
type TableData = {
projects: Project[];
nextProjectId: number;
nextTaskId: number;
};
const getInitialData = (): TableData => {
const data = tableContext?.loadData() as TableData | undefined;
return (
data || {
projects: [],
nextProjectId: 1,
nextTaskId: 1,
}
);
};
const [projects, setProjects] = useState<Project[]>(() => getInitialData().projects);
const [nextProjectId, setNextProjectId] = useState<number>(() => getInitialData().nextProjectId);
const [nextTaskId, setNextTaskId] = useState<number>(() => getInitialData().nextTaskId);
const incrementDates = () => {
setDates((prevDates) => {
@ -201,13 +213,30 @@ const App: React.FC = () => {
});
};
const saveSpecificData = (key: string, value: any): void => {
setData((prevData: any) => {
const newData = { ...prevData, [key]: value };
const saveSpecificData = (key: string, value: unknown): void => {
setData((prevData: unknown) => {
const baseData = (typeof prevData === "object" && prevData !== null) ? prevData : {};
const newData = { ...baseData, [key]: value };
return newData;
});
};
const moveTask = (taskId: number, newDate: string) => {
setProjects((prevProjects) => {
const newProjects = prevProjects.map((project) => ({
...project,
tasks: project.tasks.map((task) =>
task.id === taskId
? { ...task, date: new Date(newDate) }
: task
),
}));
saveSpecificData("projects", newProjects);
return newProjects;
});
};
useEffect(() => {
if (tableContext) {
tableContext.saveData(data);
@ -234,6 +263,7 @@ const App: React.FC = () => {
handleTaskNameChange={handleTaskNameChange}
taskStatuses={taskStatuses}
editTaskStatus={editTaskStatus}
moveTask={moveTask}
/>
</div>
);

View file

@ -3,6 +3,11 @@ import { useEffect, useRef } from "react";
import * as React from "react";
import { Project, TaskStatus } from "./App";
import TaskCell from "./TaskCell";
import {
DndContext,
useDroppable,
DragEndEvent,
} from "@dnd-kit/core";
interface TableProps {
projects: Project[];
@ -20,6 +25,30 @@ interface TableProps {
handleTaskNameChange: (id: number, newName: string) => void;
taskStatuses: TaskStatus[];
editTaskStatus: (id: number, newStatusId: string) => void;
moveTask: (taskId: number, newDate: string) => void;
}
function DroppableCell({
id,
children,
onClick,
}: {
id: string;
children: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLTableCellElement>;
}) {
const { setNodeRef } = useDroppable({ id });
return (
<td
ref={setNodeRef}
className="taskcell-enclosure"
data-droppable-id={id}
onClick={onClick}
>
{children}
</td>
);
}
const Table: React.FC<TableProps> = ({
@ -35,6 +64,7 @@ const Table: React.FC<TableProps> = ({
handleTaskNameChange,
taskStatuses,
editTaskStatus,
moveTask,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const newProjectInputRef = useRef<HTMLInputElement | null>(null);
@ -67,119 +97,149 @@ const Table: React.FC<TableProps> = ({
}, [containerRef]);
return (
<div>
<div className="table-container" ref={containerRef}>
<table className="table headings-center">
<thead className="table-header">
<tr>
<th>Projects</th>
{dates.map((date) => {
const dateObj = parseISO(date);
return (
<th className="date" key={date}>
<div className="date-header">
<div>{format(dateObj, "EEEE")}</div>
<div>
{format(dateObj, "yyyy-MM-dd")}
<DndContext
onDragEnd={(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const taskId = Number(active.id);
const [, newDate] = String(over.id).split("::");
moveTask(taskId, newDate);
}}
>
<div>
<div className="table-container" ref={containerRef}>
<table className="table headings-center">
<thead className="table-header">
<tr>
<th>Projects</th>
{dates.map((date) => {
const dateObj = parseISO(date);
return (
<th className="date" key={date}>
<div className="date-header">
<div>
{format(dateObj, "EEEE")}
</div>
<div>
{format(
dateObj,
"yyyy-MM-dd"
)}
</div>
</div>
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.id}>
<td key={project.id} className="project-column">
<input
ref={
project.id === nextProjectId - 1
? newProjectInputRef
: null
}
type="text"
value={project.name}
className="project-input"
placeholder="New project"
onBlur={(e) => {
if (e.target.value === "")
removeProject(project.id);
}}
onChange={(e) =>
handleProjectNameChange(
project.id,
e.target.value
)
}
/>
</td>
{dates.map((date, index) => (
<td
className="taskcell-enclosure"
key={`${project.id}:${date}:${index}`}
onClick={(e) => {
if (e.target === e.currentTarget) {
addTaskToProject(project, date);
setTimeout(() => {
if (
newTaskInputRef.current
) {
newTaskInputRef.current.focus();
}
}, 0);
}
}}
>
{project.tasks
.filter(
(task) =>
new Date(
task.date
).toISOString() ===
new Date(date).toISOString()
)
.map((task) => (
<TaskCell
task={task}
projectName={project.name}
removeTask={removeTask}
key={task.id}
inputRef={
task.id ===
nextTaskId - 1
? newTaskInputRef
: null
}
handleTaskNameChange={
handleTaskNameChange
}
status={task.status}
taskStatuses={taskStatuses}
editTaskStatus={
editTaskStatus
}
/>
))}
</td>
))}
</th>
);
})}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.id}>
<td
key={project.id}
className="project-column"
>
<input
ref={
project.id === nextProjectId - 1
? newProjectInputRef
: null
}
type="text"
value={project.name}
className="project-input"
placeholder="New project"
onBlur={(e) => {
if (e.target.value === "")
removeProject(project.id);
}}
onChange={(e) =>
handleProjectNameChange(
project.id,
e.target.value
)
}
/>
</td>
{dates.map((date, index) => (
<DroppableCell
id={`${project.id}::${date}`}
onClick={(e) => {
if (
e.target === e.currentTarget
) {
addTaskToProject(
project,
date
);
setTimeout(() => {
newTaskInputRef.current?.focus();
}, 0);
}
}}
key={`${project.id}-${date}`} // ADD KEY HERE
>
{project.tasks
.filter(
(task) =>
new Date(
task.date
).toISOString() ===
new Date(
date
).toISOString()
)
.map((task) => (
<TaskCell
key={task.id}
task={task}
projectName={
project.name
}
removeTask={removeTask}
inputRef={
task.id ===
nextTaskId - 1
? newTaskInputRef
: null
}
handleTaskNameChange={
handleTaskNameChange
}
status={task.status}
taskStatuses={
taskStatuses
}
editTaskStatus={
editTaskStatus
}
/>
))}
</DroppableCell>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="new-project-container">
<input
type="text"
className="project-input"
placeholder="New project"
onChange={(e) => {
createNewProject(
e.target.value,
newProjectInputRef
);
e.target.value = "";
}}
/>
</div>
</div>
<div className="new-project-container">
<input
type="text"
className="project-input"
placeholder="New project"
onChange={(e) => {
createNewProject(e.target.value, newProjectInputRef);
e.target.value = "";
}}
/>
</div>
</div>
</DndContext>
);
};

View file

@ -5,6 +5,7 @@ import {
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { useDraggable } from "@dnd-kit/core";
const transColor = (color: string, percent: number): string => {
const num = parseInt(color.replace("#", ""), 16);
@ -55,81 +56,102 @@ const TaskCell: React.FC<TaskCellProps> = ({
taskStatuses,
editTaskStatus,
}) => {
const taskColor = status.color;
// const taskColor = status.color;
const taskBackground = `${status.color}33`;
const darkTaskTextColor = transColor(status.color, -40);
// const darkTaskTextColor = transColor(status.color, -40);
const lightTaskTextColor = transColor(status.color, 25);
const lightTaskBackgroundColor = `${transColor(status.color, 25)}33`;
// const lightTaskBackgroundColor = `${transColor(status.color, 25)}33`;
const placeholderColor = hexToRgba(lightTaskTextColor, 0.50);
const placeholderColor = hexToRgba(lightTaskTextColor, 0.5);
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: task.id,
});
return (
<div className="task-cell" style={{ backgroundColor: taskBackground }}>
<div
className="task-cell-header"
style={{
backgroundColor: taskBackground,
color: lightTaskTextColor,
}}
>
<div className="project-name-repeat">
{projectName}
</div>
<Listbox
value={status.id}
onChange={(value) => editTaskStatus(task.id, value)}
>
<div className="relative">
<ListboxButton
className="task-cell-status-indicator"
style={{
border: `1px solid ${lightTaskTextColor}`,
color: lightTaskTextColor,
}}
>
{status.name}
</ListboxButton>
<ListboxOptions className="task-cell-dropdown-options">
{taskStatuses.map((s) => (
<ListboxOption
key={s.id}
value={s.id}
className={({ active }) =>
`task-cell-dropdown-option ${active ? "active" : ""
}`
}
>
{s.name}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
<div className="task-cell-content">
<input
style={{ color: lightTaskTextColor }}
placeholder="Task name"
onBlur={(e) => {
if (e.target.value === "") removeTask(task.id);
}}
ref={inputRef}
onChange={(e) =>
handleTaskNameChange(task.id, e.target.value)
}
value={task.name}
className={`task-input-${task.id}`}
></input>
<style>
{`
.task-input-${task.id}::placeholder {
color: ${placeholderColor};
}
`}
</style>
</div>
</div>
);
<div
ref={setNodeRef}
className="task-cell"
style={{
backgroundColor: taskBackground,
...(transform
? {
transform: `translate(${transform.x}px, ${transform.y}px)`,
}
: {}),
}}
>
<div
className="task-cell-header"
style={{
backgroundColor: taskBackground,
color: lightTaskTextColor,
}}
>
{/* ✅ Only this part is draggable */}
<div
{...listeners}
{...attributes}
className="project-name-repeat"
style={{
cursor: "grab",
userSelect: "none",
}}
>
{projectName}
</div>
<Listbox
value={status.id}
onChange={(value) => editTaskStatus(task.id, value)}
>
<div className="relative">
<ListboxButton
className="task-cell-status-indicator"
style={{
border: `1px solid ${lightTaskTextColor}`,
color: lightTaskTextColor,
}}
>
{status.name}
</ListboxButton>
<ListboxOptions className="task-cell-dropdown-options">
{taskStatuses.map((s) => (
<ListboxOption
key={s.id}
value={s.id}
className={({ active }) =>
`task-cell-dropdown-option ${active ? "active" : ""}`
}
>
{s.name}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
<div className="task-cell-content">
<input
value={task.name}
onChange={(e) => handleTaskNameChange(task.id, e.target.value)}
onBlur={(e) => {
if (e.target.value === "") removeTask(task.id);
}}
ref={inputRef}
className={`task-input-${task.id}`}
placeholder="Task name"
style={{ color: lightTaskTextColor }}
/>
<style>{`
.task-input-${task.id}::placeholder {
color: ${placeholderColor};
}
`}</style>
</div>
</div>
);
};
export default TaskCell;

View file

@ -5,8 +5,8 @@ import AppComponent from 'src/main/components/App';
export interface AppContext {
app: App;
loadData: () => any;
saveData: (data: any) => Promise<void>;
loadData: () => unknown;
saveData: (data: unknown) => Promise<void>;
}
export const TABLE_VIEW_TYPE = 'table-view';
@ -20,10 +20,10 @@ export const useTableContext = (): AppContext | undefined => {
export class TableView extends ItemView {
root: Root | null = null;
loadData: () => any;
saveData: (data: any) => Promise<void>;
loadData: () => unknown;
saveData: (data: unknown) => Promise<void>;
constructor(leaf: WorkspaceLeaf, loadData: () => any, saveData: (data: any) => Promise<void>) {
constructor(leaf: WorkspaceLeaf, loadData: () => unknown, saveData: (data: unknown) => Promise<void>) {
super(leaf);
this.loadData = loadData;
this.saveData = saveData;