From 2133d2e03957d9069c95119382e1eb90cb1100b3 Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Fri, 14 Aug 2026 10:10:44 +0100 Subject: [PATCH 1/6] feat: parent task chip, due-date split, timestamp tooltip, subtask creation - Display a parent task chip in the task edit modal (with remove/select), and split the due-date input into separate date/time fields (#762). - Move task timestamp display (created/modified/completed) out of the inline summary into a clock-icon tooltip on the edit modal's title row (#764). - Rework "Add a subtask": clicking it now opens the same task edit/detail modal used everywhere else, seeded with a blank, unsaved draft, instead of adding a "Subtask of X" chip before the task input (#774). The draft is only actually created once its name has been genuinely edited (via usePlayerItemModal's existing no-op-edit guard); closing the modal without editing the name discards the draft with no API call. - Add PlayerItemList `hiddenItemIds` (keep an item deep-linkable without rendering it as a row) and `onModalClose` (notify the caller which item's modal just closed) to support the draft-subtask flow. --- .../PlayerItemList/PlayerItemList.tsx | 40 +++- .../TasksPanel/TasksPanel.module.scss | 21 ++ .../components/TasksPanel/TasksPanel.test.tsx | 110 ++++++++- .../src/components/TasksPanel/TasksPanel.tsx | 224 ++++++++++++------ .../components/TasksPanel/useTasksPanel.tsx | 98 +++++++- frontend/src/utils/formatUtils.test.ts | 69 ++++-- frontend/src/utils/formatUtils.ts | 42 +++- 7 files changed, 478 insertions(+), 126 deletions(-) diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.tsx b/frontend/src/components/PlayerItemList/PlayerItemList.tsx index 9d0f2e9c..0617d707 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.tsx +++ b/frontend/src/components/PlayerItemList/PlayerItemList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import classNames from "classnames"; import Button from "../Button/Button"; @@ -33,6 +33,8 @@ interface PlayerItemListProps getItemKey?: (item: T, index: number) => string | number; renderItemMeta?: (item: T) => React.ReactNode; renderEditSummary?: (item: T, saveHelpers: SaveStatusHelpers) => React.ReactNode; + /** Rendered next to the name input in the edit modal's title row (e.g. an icon button). */ + renderTitleRowActions?: (item: T) => React.ReactNode; onEdit?: (item: T, name: string, callbacks?: SaveCallbacks) => void; onDelete?: (item: T) => void; hoverEdit?: boolean; @@ -47,6 +49,10 @@ interface PlayerItemListProps /** Called once the requested `openItemId` has been opened, so the caller can clear it. */ onOpenItemHandled?: () => void; getChildren?: (item: T) => T[] | undefined; + /** Ids of items present in `items` (e.g. for the deep-link lookup) that should not be rendered as rows. */ + hiddenItemIds?: Set; + /** Called with the item whose edit modal just closed (via Close, backdrop, or Escape). */ + onModalClose?: (item: T) => void; } export default function PlayerItemList({ @@ -59,6 +65,7 @@ export default function PlayerItemList) { const { activeFilterKey, @@ -125,6 +134,13 @@ export default function PlayerItemList { + if (activeItem) onModalClose?.(activeItem); + handleModalClose(); + }, [activeItem, onModalClose, handleModalClose]); + const canToggleComplete = typeof onToggleComplete === "function"; const canEdit = typeof onEdit === "function"; const canDelete = typeof onDelete === "function"; @@ -140,16 +156,23 @@ export default function PlayerItemList { + if (!hiddenItemIds || hiddenItemIds.size === 0) return displayItems; + return displayItems.filter((item) => item.id === undefined || !hiddenItemIds.has(item.id)); + }, [displayItems, hiddenItemIds]); + // Sort/filter controls only apply to top-level items; a child keeps its // place directly after its parent (in `getChildren`'s order) rather than // being reordered independently. const flatDisplayItems = useMemo(() => { - if (!getChildren) return displayItems; - const topLevel = displayItems.filter( + if (!getChildren) return visibleDisplayItems; + const topLevel = visibleDisplayItems.filter( (item) => item.id === undefined || !childIds.has(item.id) ); return topLevel.flatMap((item) => [item, ...(getChildren(item) ?? [])]); - }, [displayItems, getChildren, childIds]); + }, [visibleDisplayItems, getChildren, childIds]); const renderRow = (item: T): React.ReactNode => ( <> @@ -283,7 +306,7 @@ export default function PlayerItemList setConfirmingDelete(false) : undefined} backLabel="Back" > @@ -328,17 +351,20 @@ export default function PlayerItemList { if (event.key === "Enter") handleEditSave(); - if (event.key === "Escape") handleModalClose(); + if (event.key === "Escape") closeModal(); }} /> ) : null} + {renderTitleRowActions && liveActiveItem + ? renderTitleRowActions(liveActiveItem) + : null} ) : null} {modalSummary ? (
{modalSummary}
) : null}
- {canDelete ? ( diff --git a/frontend/src/components/TasksPanel/TasksPanel.module.scss b/frontend/src/components/TasksPanel/TasksPanel.module.scss index ca842fd1..b38d1f24 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.module.scss +++ b/frontend/src/components/TasksPanel/TasksPanel.module.scss @@ -99,6 +99,27 @@ gap: sp.$spacing-sm; } +.timestampButton { + flex-shrink: 0; + height: sp.$form-control-height; + width: sp.$form-control-height; + padding: 0; + border: 1px solid rgba(c.$color-border-primary, 0.35); + border-radius: sp.$form-control-radius; + background: transparent; + color: inherit; + font-size: 1rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + border-color: rgba(c.$color-border-primary, 0.55); + } +} + .timestampLabel { font-weight: 600; margin-bottom: 2px; diff --git a/frontend/src/components/TasksPanel/TasksPanel.test.tsx b/frontend/src/components/TasksPanel/TasksPanel.test.tsx index 6ddb5e8f..a503b5cf 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.test.tsx +++ b/frontend/src/components/TasksPanel/TasksPanel.test.tsx @@ -359,7 +359,7 @@ describe("TasksPanel", () => { expect(screen.queryByText("Child subtask")).not.toBeInTheDocument(); }); - it("pre-fills the add-task form with a parent chip via the add-subtask row action", async () => { + it("opens the task detail modal for a blank draft subtask without creating one yet", async () => { const user = userEvent.setup({ pointerEventsCheck: 0 }); mockUseTasks.mockReturnValue({ isLoading: false, @@ -369,15 +369,65 @@ describe("TasksPanel", () => { await user.click(screen.getByRole("button", { name: "Add subtask to Parent project task" })); - expect(screen.getByText(/Subtask of Parent project task/)).toBeInTheDocument(); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByLabelText("task name")).toHaveValue(""); + expect(createMutate).not.toHaveBeenCalled(); + }); + + it("creates the subtask only once its draft name has actually been edited, then opens the persisted task", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const newSubtask = { ...childTask, id: 7, name: "New task" }; + createMutate.mockImplementation((_data, callbacks) => { + callbacks?.onSuccess?.(newSubtask); + }); + mockUseTasks.mockReturnValue({ + isLoading: false, + data: [parentTask], + }); + const { rerender } = renderTasksPanel(); + + await user.click(screen.getByRole("button", { name: "Add subtask to Parent project task" })); + const dialog = await screen.findByRole("dialog"); + const input = within(dialog).getByLabelText("task name"); + await user.type(input, "New task"); + await user.tab(); + + expect(createMutate).toHaveBeenCalledWith( + { name: "New task", parent: 3 }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + ); + + // The new subtask isn't in `items` until the tasks query refetches with it included. + mockUseTasks.mockReturnValue({ + isLoading: false, + data: [parentTask, newSubtask], + }); + rerender( + + + , + ); - const input = screen.getByLabelText("new task"); - await user.type(input, "Buy groceries"); - await user.click(screen.getByRole("button", { name: "Add subtask" })); + const reopenedDialog = await screen.findByRole("dialog"); + expect(within(reopenedDialog).getByDisplayValue("New task")).toBeInTheDocument(); + }); + + it("discards the draft subtask, without creating anything, when its modal is closed unedited", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + mockUseTasks.mockReturnValue({ + isLoading: false, + data: [parentTask], + }); + renderTasksPanel(); + + await user.click(screen.getByRole("button", { name: "Add subtask to Parent project task" })); + const dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Close" })); await waitFor(() => { - expect(createMutate).toHaveBeenCalledWith({ name: "Buy groceries", parent: 3 }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + expect(createMutate).not.toHaveBeenCalled(); }); it("disables the parent picker for a task that already has subtasks", async () => { @@ -406,7 +456,7 @@ describe("TasksPanel", () => { ); const dueDateInput = screen.getByLabelText("Due date"); - await user.type(dueDateInput, "2026-06-01T09:00"); + await user.type(dueDateInput, "2026-06-01"); await user.tab(); await waitFor(() => { @@ -416,5 +466,51 @@ describe("TasksPanel", () => { ); }); }); + + it("defaults the date to today when only a time is set", async () => { + const user = userEvent.setup(); + renderTasksPanel(); + + await user.click( + screen.getAllByRole("button", { name: "Edit task Morning routine" })[0], + ); + + const dueTimeInput = screen.getByLabelText("Due time"); + await user.type(dueTimeInput, "0900"); + await user.tab(); + + await waitFor(() => { + expect(updateMutate).toHaveBeenCalledWith( + { id: 1, data: { due_at: expect.any(String) } }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + ); + }); + + const lastCall = updateMutate.mock.calls.at(-1) as [{ data: { due_at: string } }, unknown]; + const committedDate = new Date(lastCall[0].data.due_at); + const today = new Date(); + expect(committedDate.getFullYear()).toBe(today.getFullYear()); + expect(committedDate.getMonth()).toBe(today.getMonth()); + expect(committedDate.getDate()).toBe(today.getDate()); + }); + }); + + describe("timestamps tooltip", () => { + it("shows Created/Modified/Completed on click of the clock button", async () => { + const user = userEvent.setup(); + renderTasksPanel(); + + await user.click( + screen.getAllByRole("button", { name: "Edit task Morning routine" })[0], + ); + + expect(screen.queryByText("Created", { selector: "div" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "View task timestamps" })); + + expect(screen.getByText("Created", { selector: "div" })).toBeInTheDocument(); + expect(screen.getByText("Modified", { selector: "div" })).toBeInTheDocument(); + expect(screen.getByText("Completed", { selector: "div" })).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/components/TasksPanel/TasksPanel.tsx b/frontend/src/components/TasksPanel/TasksPanel.tsx index 3da46e7a..148ea471 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.tsx +++ b/frontend/src/components/TasksPanel/TasksPanel.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef } from "react"; import classNames from "classnames"; import EntitySearchInput from "../EntitySearchInput/EntitySearchInput"; @@ -6,7 +6,7 @@ import Button from "../Button/Button"; import PlayerItemList from "../PlayerItemList/PlayerItemList"; import Tooltip from "../Tooltip/Tooltip"; import { isTaskComplete, taskSortOptions, useTasksPanel, type ItemRecord } from "./useTasksPanel"; -import { toDatetimeLocalValue, fromDatetimeLocalValue } from "../../utils/formatUtils"; +import { toDateInputValue, toTimeInputValue, fromDateAndTimeInputValues } from "../../utils/formatUtils"; import styles from "./TasksPanel.module.scss"; interface TasksPanelProps { @@ -31,9 +31,11 @@ export default function TasksPanel({ visibleTasks, getChildren, topLevelTasks, - addSubtaskParent, + pendingOpenTaskId, + hiddenItemIds, startAddSubtask, - clearAddSubtaskParent, + clearPendingOpenTaskId, + discardDraftTask, handleCreateTask, handleSubmitForm, handleEdit, @@ -48,35 +50,27 @@ export default function TasksPanel({ updateTask, } = useTasksPanel(openTaskId, onOpenNote); + // Only one task's edit summary is ever open at a time (it renders inside a modal), so a + // single pair of refs is enough to read the sibling input's value when committing due_at. + const dueDateInputRef = useRef(null); + const dueTimeInputRef = useRef(null); + if (isLoading) return

Loading tasks...

; return (
- {addSubtaskParent && ( - - Subtask of {addSubtaskParent.name} - - - )} setNewName(v)} - onCreate={(name) => handleCreateTask(name, { parent: addSubtaskParent?.id ?? undefined })} - placeholder={addSubtaskParent ? "New subtask name" : "New task name"} + onCreate={(name) => handleCreateTask(name)} + placeholder="New task name" className={styles.addTaskInput} /> @@ -108,27 +102,19 @@ export default function TasksPanel({ ); }} renderEditSummary={(taskItem, saveHelpers) => { + if (taskItem.id < 0) { + // An unsaved draft subtask: nothing to show or edit here yet + // (due date, parent, notes) until it's actually been created. + return
Type a name to create this subtask.
; + } + const summary = getTaskEditSummary(taskItem); const hasSubtasks = (taskItem.subtask_count ?? 0) > 0; const parentOptions = topLevelTasks.filter((t) => t.id !== taskItem.id); + const parentTask = topLevelTasks.find((t) => t.id === taskItem.parent) ?? null; return ( <> -
-
-
Created
-
{summary.created}
-
-
-
Modified
-
{summary.modified}
-
-
-
Completed
-
{summary.completed}
-
-
-
Total time: {summary.totalTime}
@@ -154,20 +140,51 @@ export default function TasksPanel({ })() ) : null}
-
); }} + renderTitleRowActions={(task) => { + if (task.id < 0) return null; + const summary = getTaskEditSummary(task); + return ( + +
+
Created
+
{summary.created}
+
+
+
Modified
+
{summary.modified}
+
+
+
Completed
+
{summary.completed}
+
+
+ } + > + + + ); + }} hoverEdit renderRowActions={(task) => ( <> @@ -262,8 +335,13 @@ export default function TasksPanel({ )} onEdit={handleEdit} onDelete={handleDelete} - openItemId={openTaskId} - onOpenItemHandled={onOpenTaskHandled} + openItemId={openTaskId ?? pendingOpenTaskId} + onOpenItemHandled={() => { + onOpenTaskHandled?.(); + clearPendingOpenTaskId(); + }} + hiddenItemIds={hiddenItemIds} + onModalClose={discardDraftTask} sortOptions={taskSortOptions} controls={