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", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@headlessui/react": "^2.2.2", "@headlessui/react": "^2.2.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
@ -50,6 +52,59 @@
"w3c-keyname": "^2.2.4" "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": { "node_modules/@esbuild/android-arm": {
"version": "0.17.3", "version": "0.17.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.3.tgz",
@ -2379,8 +2434,7 @@
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
"dev": true
}, },
"node_modules/tsutils": { "node_modules/tsutils": {
"version": "3.21.0", "version": "3.21.0",

View file

@ -24,6 +24,8 @@
"typescript": "4.7.4" "typescript": "4.7.4"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@headlessui/react": "^2.2.2", "@headlessui/react": "^2.2.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.469.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 { useEffect, useState } from "react";
import { useTableContext } from "../views/TableView"; import { useTableContext } from "../views/TableView";
import Table from "./Table"; import Table from "./Table";
@ -44,19 +45,30 @@ const App: React.FC = () => {
const [dates, setDates] = useState(getWeekDates(new Date())); 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( type TableData = {
() => tableContext!.loadData().projects as Project[] projects: Project[];
); nextProjectId: number;
const [nextProjectId, setNextProjectId] = useState( nextTaskId: number;
() => tableContext!.loadData().nextProjectId as number };
);
const [nextTaskId, setNextTaskId] = useState( const getInitialData = (): TableData => {
() => tableContext!.loadData().nextTaskId as number 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 = () => { const incrementDates = () => {
setDates((prevDates) => { setDates((prevDates) => {
@ -201,13 +213,30 @@ const App: React.FC = () => {
}); });
}; };
const saveSpecificData = (key: string, value: any): void => { const saveSpecificData = (key: string, value: unknown): void => {
setData((prevData: any) => { setData((prevData: unknown) => {
const newData = { ...prevData, [key]: value }; const baseData = (typeof prevData === "object" && prevData !== null) ? prevData : {};
const newData = { ...baseData, [key]: value };
return newData; 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(() => { useEffect(() => {
if (tableContext) { if (tableContext) {
tableContext.saveData(data); tableContext.saveData(data);
@ -234,6 +263,7 @@ const App: React.FC = () => {
handleTaskNameChange={handleTaskNameChange} handleTaskNameChange={handleTaskNameChange}
taskStatuses={taskStatuses} taskStatuses={taskStatuses}
editTaskStatus={editTaskStatus} editTaskStatus={editTaskStatus}
moveTask={moveTask}
/> />
</div> </div>
); );

View file

@ -3,6 +3,11 @@ import { useEffect, useRef } from "react";
import * as React from "react"; import * as React from "react";
import { Project, TaskStatus } from "./App"; import { Project, TaskStatus } from "./App";
import TaskCell from "./TaskCell"; import TaskCell from "./TaskCell";
import {
DndContext,
useDroppable,
DragEndEvent,
} from "@dnd-kit/core";
interface TableProps { interface TableProps {
projects: Project[]; projects: Project[];
@ -20,6 +25,30 @@ interface TableProps {
handleTaskNameChange: (id: number, newName: string) => void; handleTaskNameChange: (id: number, newName: string) => void;
taskStatuses: TaskStatus[]; taskStatuses: TaskStatus[];
editTaskStatus: (id: number, newStatusId: string) => void; 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> = ({ const Table: React.FC<TableProps> = ({
@ -35,6 +64,7 @@ const Table: React.FC<TableProps> = ({
handleTaskNameChange, handleTaskNameChange,
taskStatuses, taskStatuses,
editTaskStatus, editTaskStatus,
moveTask,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const newProjectInputRef = useRef<HTMLInputElement | null>(null); const newProjectInputRef = useRef<HTMLInputElement | null>(null);
@ -67,119 +97,149 @@ const Table: React.FC<TableProps> = ({
}, [containerRef]); }, [containerRef]);
return ( return (
<div> <DndContext
<div className="table-container" ref={containerRef}> onDragEnd={(event: DragEndEvent) => {
<table className="table headings-center"> const { active, over } = event;
<thead className="table-header"> if (!over || active.id === over.id) return;
<tr>
<th>Projects</th> const taskId = Number(active.id);
{dates.map((date) => { const [, newDate] = String(over.id).split("::");
const dateObj = parseISO(date);
return ( moveTask(taskId, newDate);
<th className="date" key={date}> }}
<div className="date-header"> >
<div>{format(dateObj, "EEEE")}</div> <div>
<div> <div className="table-container" ref={containerRef}>
{format(dateObj, "yyyy-MM-dd")} <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>
</div> </th>
</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>
))}
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {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>
<div className="new-project-container"> </DndContext>
<input
type="text"
className="project-input"
placeholder="New project"
onChange={(e) => {
createNewProject(e.target.value, newProjectInputRef);
e.target.value = "";
}}
/>
</div>
</div>
); );
}; };

View file

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

View file

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