Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f2ea0b6
Initial plan
Copilot Dec 30, 2025
d1d7223
Implement task to event conversion and migrate tasks to dnd-kit
Copilot Dec 30, 2025
e1f9339
Pass dateInView to useEventDNDActions for correct event timing
Copilot Dec 30, 2025
2bc3ae7
Fix DraggableTask tests after migrating to dnd-kit
Copilot Dec 30, 2025
1f89668
Address code review feedback: improve code clarity and documentation
Copilot Dec 30, 2025
4b49359
Address code review feedback: use Schema_Event_Regular type, restore …
Copilot Dec 30, 2025
81ebc37
Use Schema_Event instead of Schema_Event_Regular for return type
Copilot Dec 30, 2025
dc5a0b8
Infer dateInView from URL context and create task mock utility
Copilot Dec 30, 2025
abaed15
Address PR review: move mock utility, fix Task naming, add allday sup…
Copilot Dec 30, 2025
68a730a
Add error handling for task-to-event conversion and move mock utility…
Copilot Dec 30, 2025
a23883e
Fix code review issues: import Task type, fix race condition, prevent…
Copilot Dec 30, 2025
12a1430
Merge remote-tracking branch 'origin/main' into copilot/add-dnd-task-…
Copilot Dec 31, 2025
c89b828
Refactor error handling: remove ref/useEffect, extract logic to teste…
Copilot Jan 2, 2026
5af5d6e
Remove doc comment and replace TODO with explanation of current approach
Copilot Jan 2, 2026
ee85d21
Address code review: restore task.test.util.ts, simplify conditionals…
Copilot Jan 2, 2026
35691f3
Fix missing getSnappedMinutes import in useEventDNDActions
Copilot Jan 2, 2026
2be21b3
Merge remote-tracking branch 'origin/main' into copilot/add-dnd-task-…
Copilot Jan 2, 2026
dbec324
Rename handleTaskToEventConversion to task.util and merge latest from…
Copilot Jan 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions packages/web/src/common/hooks/useEventDNDActions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useCallback } from "react";
import { Active, DragEndEvent, Over, useDndMonitor } from "@dnd-kit/core";
import { Categories_Event } from "@core/types/event.types";
import dayjs from "@core/util/date/dayjs";
import dayjs, { Dayjs } from "@core/util/date/dayjs";
import { getUserId } from "@web/auth/auth.util";
import {
ID_GRID_ALLDAY_ROW,
ID_GRID_MAIN,
Expand All @@ -12,12 +13,16 @@ import {
setFloatingReferenceAtCursor,
} from "@web/common/hooks/useOpenAtCursor";
import { useUpdateEvent } from "@web/common/hooks/useUpdateEvent";
import { Task } from "@web/common/types/task.types";
import { Schema_GridEvent } from "@web/common/types/web.event.types";
import { reorderGrid } from "@web/common/utils/dom/grid-organization.util";
import { getCalendarEventElementFromGrid } from "@web/common/utils/event/event.util";
import { selectEventById } from "@web/ducks/events/selectors/event.selectors";
import { createEventSlice } from "@web/ducks/events/slices/event.slice";
import { store } from "@web/store";
import { useAppDispatch } from "@web/store/store.hooks";
import { getSnappedMinutes } from "@web/views/Day/util/agenda/agenda.util";
import { convertTaskToEvent } from "@web/views/Day/util/task/convertTaskToEvent";

const shouldSaveImmediately = (_id: string) => {
const storeEvent = selectEventById(store.getState(), _id);
Expand All @@ -34,8 +39,38 @@ const setReference = (_id: string) => {
});
};

export function useEventDNDActions() {
export function useEventDNDActions(dateInView?: Dayjs) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont add dateInView as an arg here. Instead, infer the data from the URL

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to infer dateInView from URL context using the useDateInView hook. Removed the parameter from useEventDNDActions. (commit dc5a0b8)

const updateEvent = useUpdateEvent();
const dispatch = useAppDispatch();

const convertTaskToEventOnAgenda = useCallback(
async (task: Task, active: Active, over: Over, deleteTask: () => void) => {
const snappedMinutes = getSnappedMinutes(active, over);

if (snappedMinutes === null) return;

const userId = await getUserId();
if (!userId) return;

const currentDate = dateInView || dayjs();
const startTime = currentDate
.startOf("day")
.add(snappedMinutes, "minute");

// Convert task to event
const event = convertTaskToEvent(task, startTime, 15, userId);

// Delete the task
deleteTask();

// Create the event optimistically
dispatch(createEventSlice.actions.request(event));

reorderGrid();
setReference(event._id!);
},
[dispatch, dateInView],
);

const moveTimedAroundMainGridDayView = useCallback(
(event: Schema_GridEvent, active: Active, over: Over) => {
Expand Down Expand Up @@ -126,25 +161,37 @@ export function useEventDNDActions() {
(e: DragEndEvent) => {
const { active, over } = e;
const { data } = active;
const { view, type, event } = data.current ?? {};
const { view, type, event, task, deleteTask } = data.current ?? {};

if (!over?.id || !event) return;
if (!over?.id) return;

const switchCase = `${view}-${type}-to-${over.id}`;

switch (switchCase) {
case `day-task-to-${ID_GRID_MAIN}`:
if (task && deleteTask) {
convertTaskToEventOnAgenda(task, active, over, deleteTask);
}
break;
case `day-${Categories_Event.ALLDAY}-to-${ID_GRID_MAIN}`:
moveAllDayToMainGridDayView(event, active, over);
if (event) {
moveAllDayToMainGridDayView(event, active, over);
}
break;
case `day-${Categories_Event.TIMED}-to-${ID_GRID_MAIN}`:
moveTimedAroundMainGridDayView(event, active, over);
if (event) {
moveTimedAroundMainGridDayView(event, active, over);
}
break;
case `day-${Categories_Event.TIMED}-to-${ID_GRID_ALLDAY_ROW}`:
moveTimedToAllDayGridDayView(event);
if (event) {
moveTimedToAllDayGridDayView(event);
}
break;
Comment thread
tyler-dane marked this conversation as resolved.
}
},
[
convertTaskToEventOnAgenda,
moveAllDayToMainGridDayView,
moveTimedAroundMainGridDayView,
moveTimedToAllDayGridDayView,
Expand Down
9 changes: 7 additions & 2 deletions packages/web/src/components/DND/Draggable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { useMergeRefs } from "@floating-ui/react";
import { Categories_Event } from "@core/types/event.types";
import { Task } from "@web/common/types/task.types";
import { Schema_GridEvent } from "@web/common/types/web.event.types";

export type DraggableDataType = Categories_Event | "task";

export interface DraggableDNDData {
type: Categories_Event;
event: Schema_GridEvent | null;
type: DraggableDataType;
event?: Schema_GridEvent | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strong type, don't let these be optional

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed all fields to required. Components now explicitly provide null values when fields don't apply (e.g., event: null for tasks, task: null for events). (commit ee85d21)

task?: Task | null;
view: "day" | "week" | "now";
deleteTask?: () => void;
}

export interface DNDChildProps
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, { act } from "react";
import { DragDropContext, Droppable } from "@hello-pangea/dnd";
import { fireEvent, screen } from "@testing-library/react";
import { render } from "@web/__tests__/__mocks__/mock.render";
import { Task } from "@web/common/types/task.types";
Expand Down Expand Up @@ -52,22 +51,7 @@ const renderDraggableTask = (
tasksProps = defaultTasksProps,
) => {
return act(() =>
render(
<DragDropContext onDragEnd={jest.fn()}>
<Droppable droppableId="test-droppable">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
<DraggableTask
task={task}
index={index}
tasksProps={tasksProps}
/>
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>,
),
render(<DraggableTask task={task} index={index} tasksProps={tasksProps} />),
);
};

Expand Down
194 changes: 127 additions & 67 deletions packages/web/src/views/Day/components/Task/DraggableTask.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

separate these components into separate files for legibility

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separated DraggableTaskHandle into its own file at DraggableTaskHandle.tsx. (commit c89b828)

Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import classNames from "classnames";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { autoUpdate, inline, offset, useFloating } from "@floating-ui/react";
import { Draggable } from "@hello-pangea/dnd";
import { DotsSixVerticalIcon } from "@phosphor-icons/react";
import { Task as ITask } from "@web/common/types/task.types";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just use Task, not ITask

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed ITask to Task throughout DraggableTask.tsx and renamed the Task component import to TaskComponent to avoid conflict. (commit abaed15)

import { getStyle } from "@web/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEvent/styled";
import { DNDChildProps, Draggable } from "@web/components/DND/Draggable";
import { Task } from "@web/views/Day/components/Task/Task";
import { useTasks } from "@web/views/Day/hooks/tasks/useTasks";

Expand Down Expand Up @@ -38,79 +39,138 @@ export function DraggableTask({
onTitleChange,
onStatusToggle,
migrateTask,
deleteTask,
} = tasksProps;

return (
<Draggable
draggableId={task.id}
index={index}
isDragDisabled={tasks.length === 1}
disableInteractiveElementBlocking
dndProps={{
id: task.id,
data: {
type: "task",
task,
view: "day",
deleteTask: () => deleteTask(task.id),
},
disabled: tasks.length === 1,
}}
as="div"
id={task.id}
className={`group relative mr-2 select-none`}
ref={(e) => {
refs.setReference(e);
update();
}}
asChild
>
{(draggableProvider, draggableSnapshot) => (
<div
{...draggableProvider.draggableProps}
id={task.id}
className={`group relative mr-2 select-none`}
style={getStyle(
draggableSnapshot,
false,
draggableProvider.draggableProps.style,
<DraggableTaskInner
task={task}
index={index}
tasks={tasks}
editingTaskId={editingTaskId}
editingTitle={editingTitle}
setSelectedTaskIndex={setSelectedTaskIndex}
onCheckboxKeyDown={onCheckboxKeyDown}
onInputBlur={onInputBlur}
onInputClick={onInputClick}
onInputKeyDown={onInputKeyDown}
onTitleChange={onTitleChange}
onStatusToggle={onStatusToggle}
migrateTask={migrateTask}
refs={refs}
floatingStyles={floatingStyles}
/>
</Draggable>
);
}

function DraggableTaskInner({
task,
index,
tasks,
editingTaskId,
editingTitle,
setSelectedTaskIndex,
onCheckboxKeyDown,
onInputBlur,
onInputClick,
onInputKeyDown,
onTitleChange,
onStatusToggle,
migrateTask,
refs,
floatingStyles,
dndProps,
}: {
task: ITask;
index: number;
tasks: ITask[];
editingTaskId: string | null;
editingTitle: string;
setSelectedTaskIndex: (index: number) => void;
onCheckboxKeyDown: (
e: React.KeyboardEvent,
taskId: string,
title: string,
) => void;
onInputBlur: (taskId: string) => void;
onInputClick: (taskId: string) => void;
onInputKeyDown: (e: React.KeyboardEvent, taskId: string) => void;
onTitleChange: (title: string) => void;
onStatusToggle: (id: string) => void;
migrateTask: (id: string, direction: "forward" | "backward") => void;
refs: ReturnType<typeof useFloating>["refs"];
floatingStyles: React.CSSProperties;
dndProps?: DNDChildProps;
}) {
const isDragging = dndProps?.isDragging ?? false;

return (
<div>
{tasks.length > 1 ? (
<button
{...(dndProps?.listeners ?? {})}
ref={refs.setFloating}
style={floatingStyles}
aria-label={`Reorder ${task.title}`}
aria-describedby={`description-${task.id}`}
onFocus={() => setSelectedTaskIndex(index)}
className={classNames(
"opacity-0",
"hover:bg-border-primary hover:cursor-grab",
"rounded-xs py-2 transition-colors",
"group-hover:opacity-100 hover:opacity-100 focus:opacity-100",
"max-w-48 text-white",
"focus:bg-white/20 focus:ring-2 focus:ring-white/50",
"focus:outline-none disabled:cursor-default disabled:opacity-0",
{
hidden: tasks.length === 1,
"opacity-100": isDragging,
},
)}
ref={(e) => {
draggableProvider.innerRef(e);
refs.setReference(e);
update();
}}
>
{tasks.length > 1 ? (
<button
{...draggableProvider.dragHandleProps}
ref={refs.setFloating}
style={floatingStyles}
aria-label={`Reorder ${task.title}`}
aria-describedby={`description-${task.id}`}
onFocus={() => setSelectedTaskIndex(index)}
className={classNames(
"opacity-0",
"hover:bg-border-primary hover:cursor-grab",
"rounded-xs py-2 transition-colors",
"group-hover:opacity-100 hover:opacity-100 focus:opacity-100",
"max-w-48 text-white",
"focus:bg-white/20 focus:ring-2 focus:ring-white/50",
"focus:outline-none disabled:cursor-default disabled:opacity-0",
{
hidden: tasks.length === 1,
"opacity-100":
draggableSnapshot.isDragging ||
draggableSnapshot.isDropAnimating,
},
)}
>
<DotsSixVerticalIcon size={24} />
</button>
) : null}
<DotsSixVerticalIcon size={24} />
</button>
) : null}

<Task
task={task}
index={index}
isEditing={editingTaskId === task.id}
onFocus={setSelectedTaskIndex}
onCheckboxKeyDown={onCheckboxKeyDown}
onInputBlur={onInputBlur}
onInputKeyDown={onInputKeyDown}
onInputClick={onInputClick}
onTitleChange={onTitleChange}
onStatusToggle={onStatusToggle}
onMigrate={migrateTask}
title={editingTaskId === task.id ? editingTitle : task.title}
/>
<Task
task={task}
index={index}
isEditing={editingTaskId === task.id}
onFocus={setSelectedTaskIndex}
onCheckboxKeyDown={onCheckboxKeyDown}
onInputBlur={onInputBlur}
onInputKeyDown={onInputKeyDown}
onInputClick={onInputClick}
onTitleChange={onTitleChange}
onStatusToggle={onStatusToggle}
onMigrate={migrateTask}
title={editingTaskId === task.id ? editingTitle : task.title}
/>

<div id={`description-${task.id}`} className="hidden">
Press space to start dragging this task.
</div>
</div>
)}
</Draggable>
<div id={`description-${task.id}`} className="hidden">
Press space to start dragging this task.
</div>
</div>
);
}
5 changes: 1 addition & 4 deletions packages/web/src/views/Day/components/TaskList/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { TaskContextMenuWrapper } from "@web/views/Day/components/ContextMenu/Ta
import { TaskListHeader } from "@web/views/Day/components/TaskList/TaskListHeader";
import { Tasks } from "@web/views/Day/components/Tasks/Tasks";
import { useTaskListInputFocus } from "@web/views/Day/components/Tasks/useTaskListInputFocus";
import { DNDTasksProvider } from "@web/views/Day/context/DNDTasksContext";
import { useTasks } from "@web/views/Day/hooks/tasks/useTasks";

export function TaskList() {
Expand Down Expand Up @@ -64,9 +63,7 @@ export function TaskList() {
className="flex flex-1 flex-col gap-2 overflow-hidden p-4"
>
<TaskContextMenuWrapper>
<DNDTasksProvider>
<Tasks />
</DNDTasksProvider>
<Tasks />
</TaskContextMenuWrapper>

<div className="mr-4 ml-2">
Expand Down
Loading
Loading