From efcd3e666a2e51138da7b7e655ae6fa760679d42 Mon Sep 17 00:00:00 2001 From: wallek Date: Tue, 12 May 2026 09:53:16 +0200 Subject: [PATCH 1/4] feat: in-progress todo state tracking - Add inprogress extension support to TodoObject type and parser - Display orange left border on in-progress todos - Add Ctrl+click and middle-click checkbox toggle for in-progress state - Remove inprogress extension when marking todo as complete - Add unit test for inprogress cleanup on completion --- src/@types/Todo.ts | 1 + src/main/Attributes.test.ts | 1 + .../DataRequest/ChangeCompleteState.test.ts | 4 +++ src/main/DataRequest/ChangeCompleteState.ts | 2 ++ src/main/DataRequest/CreateTodoObjects.ts | 4 +++ src/renderer/Grid/Row.scss | 3 +++ src/renderer/Grid/Row.tsx | 25 ++++++++++++++++++- 7 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/@types/Todo.ts b/src/@types/Todo.ts index f24a1fd7..fb9523f6 100644 --- a/src/@types/Todo.ts +++ b/src/@types/Todo.ts @@ -14,6 +14,7 @@ export interface TodoObject { priority: string | null; hidden: boolean; + inprogress: boolean; due: string | null; dueString: string | null; t: string | null; diff --git a/src/main/Attributes.test.ts b/src/main/Attributes.test.ts index d10306aa..a50c31b1 100644 --- a/src/main/Attributes.test.ts +++ b/src/main/Attributes.test.ts @@ -41,6 +41,7 @@ function todo(overrides: Partial = {}): TodoObject { notify: false, hidden: false, complete: false, + inprogress: false, lineNumber: 1, body: "", string: "", diff --git a/src/main/DataRequest/ChangeCompleteState.test.ts b/src/main/DataRequest/ChangeCompleteState.test.ts index 059c219f..e06d1679 100644 --- a/src/main/DataRequest/ChangeCompleteState.test.ts +++ b/src/main/DataRequest/ChangeCompleteState.test.ts @@ -45,6 +45,10 @@ describe("changeCompleteState", () => { changeCompleteState(todo, true); expect(createRecurringTodo).toHaveBeenCalledWith(todo, "1b"); }); + it("removes inprogress extension when marking complete", () => { + const r = changeCompleteState("My todo inprogress:1", true); + expect(r).not.toContain("inprogress:1"); + }); }); describe("change to uncompleted", () => { diff --git a/src/main/DataRequest/ChangeCompleteState.ts b/src/main/DataRequest/ChangeCompleteState.ts index 895af20f..fde682b2 100644 --- a/src/main/DataRequest/ChangeCompleteState.ts +++ b/src/main/DataRequest/ChangeCompleteState.ts @@ -26,6 +26,8 @@ function changeCompleteState(string: string, state: boolean): string { JsTodoTxtObject.setPriority(null); JsTodoTxtObject.setExtension("pri", currentPriority); } + + JsTodoTxtObject.removeExtension("inprogress"); } else { JsTodoTxtObject.setCompleted(null); restorePreviousPriority(JsTodoTxtObject); diff --git a/src/main/DataRequest/CreateTodoObjects.ts b/src/main/DataRequest/CreateTodoObjects.ts index 2f411299..16cb01cf 100644 --- a/src/main/DataRequest/CreateTodoObjects.ts +++ b/src/main/DataRequest/CreateTodoObjects.ts @@ -61,6 +61,9 @@ function createTodoObject( const hidden = extensions.some( (extension) => extension.key === "h" && extension.value === "1", ); + const inprogress = extensions.some( + (extension) => extension.key === "inprogress" && extension.value === "1", + ); const stringPm: string | null = extensions.find((extension) => extension.key === "pm")?.value || null; const rec = @@ -111,6 +114,7 @@ function createTodoObject( tString, rec, hidden, + inprogress, pm, string: content, }; diff --git a/src/renderer/Grid/Row.scss b/src/renderer/Grid/Row.scss index 00b62cf8..47c8fb94 100644 --- a/src/renderer/Grid/Row.scss +++ b/src/renderer/Grid/Row.scss @@ -30,6 +30,9 @@ &[data-hidden="true"] { filter: opacity(50%) grayscale(100%); } + &[data-inprogress="true"] { + border-left-color: var(--semantic-warning); + } &:last-child { border-bottom-left-radius: 0.25em; } diff --git a/src/renderer/Grid/Row.tsx b/src/renderer/Grid/Row.tsx index 7f49c382..db12d4f7 100644 --- a/src/renderer/Grid/Row.tsx +++ b/src/renderer/Grid/Row.tsx @@ -3,6 +3,7 @@ import Checkbox from "@mui/material/Checkbox"; import Tooltip from "@mui/material/Tooltip"; import CircleChecked from "@mui/icons-material/CheckCircle"; import CircleUnchecked from "@mui/icons-material/RadioButtonUnchecked"; +import IndeterminateCheckBox from "@mui/icons-material/IndeterminateCheckBox"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import EventAvailableIcon from "@mui/icons-material/EventAvailable"; import EventNoteIcon from "@mui/icons-material/EventNote"; @@ -81,6 +82,19 @@ const Row: React.FC = memo( ); }; + const handleInprogressToggle = (): void => { + if (todoObject.complete) return; + const attributeValue = todoObject.inprogress ? "" : "1"; + ipcRenderer.send( + "updateTodoObject", + todoObject.lineNumber, + todoObject.string, + "inprogress", + attributeValue, + true, + ); + }; + const preventDialog = (clickedElement): boolean => { let match = false; @@ -164,6 +178,7 @@ const Row: React.FC = memo( className="row" data-complete={todoObject.complete} data-hidden={todoObject.hidden} + data-inprogress={todoObject.inprogress} onClick={(event) => handleRowClick(event)} onKeyDown={(event) => handleRowClick(event)} onContextMenu={(event) => @@ -180,9 +195,17 @@ const Row: React.FC = memo( } checkedIcon={} + indeterminateIcon={} tabIndex={0} checked={todoObject.complete} + indeterminate={todoObject.inprogress} onChange={handleCheckboxChange} + onMouseDown={(event) => { + if (event.button === 1 || event.ctrlKey) { + event.preventDefault(); + handleInprogressToggle(); + } + }} /> {(settings.sorting[0].value != "priority" || settings.fileSorting) && @@ -245,7 +268,7 @@ const Row: React.FC = memo( )} {todoObject.hidden && ( - + )} Date: Mon, 18 May 2026 16:37:47 +0200 Subject: [PATCH 2/4] feat: sort in-progress todos to top of groups - Add sortInProgressFirst function to sort in-progress items first within each group - Apply in-progress sorting after all other sorting in dataRequest - Add unit tests for sortInProgressFirst function - Fix checkbox event handling to prevent onChange when toggling in-progress state - Add inprogress pattern to renderer for proper syntax highlighting - Remove attributeValue requirement check in handleWriteTodoToFile --- src/@types/Todo.ts | 1 + src/main/DataRequest/DataRequest.ts | 4 +- src/main/DataRequest/Sort.test.ts | 82 ++++++++- src/main/DataRequest/Sort.ts | 14 +- src/renderer/Grid/Renderer.tsx | 6 + src/renderer/Grid/Row.test.tsx | 271 ++++++++++++++++++++++++++++ src/renderer/Grid/Row.tsx | 18 ++ 7 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 src/renderer/Grid/Row.test.tsx diff --git a/src/@types/Todo.ts b/src/@types/Todo.ts index fb9523f6..b35e402d 100644 --- a/src/@types/Todo.ts +++ b/src/@types/Todo.ts @@ -51,6 +51,7 @@ export type AttributeKey = | "rec" | "pm" | "hidden" + | "inprogress" | "created" | "completed"; diff --git a/src/main/DataRequest/DataRequest.ts b/src/main/DataRequest/DataRequest.ts index b97e681c..248f29bf 100644 --- a/src/main/DataRequest/DataRequest.ts +++ b/src/main/DataRequest/DataRequest.ts @@ -10,7 +10,7 @@ import { } from "../Filters/Filters"; import { updateAttributes, attributes } from "../Attributes"; import { createTodoObjects } from "./CreateTodoObjects"; -import { sortTodoObjects } from "./Sort"; +import { sortTodoObjects, sortInProgressFirst } from "./Sort"; import { groupTodoObjects } from "./Group"; import { RequestedData, @@ -152,6 +152,8 @@ function dataRequest(passedSearchString: string = ""): RequestedData | null { }); } } + + sortInProgressFirst(todoData); } const requestedData: RequestedData = { diff --git a/src/main/DataRequest/Sort.test.ts b/src/main/DataRequest/Sort.test.ts index f88f479f..c6f24194 100644 --- a/src/main/DataRequest/Sort.test.ts +++ b/src/main/DataRequest/Sort.test.ts @@ -1,6 +1,11 @@ import { expect, describe, it, vi } from "vitest"; -import { sortTodoObjects, parseRecurrenceSortKey } from "./Sort"; +import { + sortTodoObjects, + parseRecurrenceSortKey, + sortInProgressFirst, +} from "./Sort"; import { createTodoObject } from "./CreateTodoObjects"; +import { TodoData } from "@sleek-types"; vi.mock("../Shared", () => ({ lineBreakPlaceholder: "[LB]", @@ -268,3 +273,78 @@ describe("rec sorting", () => { }); }); }); + +describe("sortInProgressFirst", () => { + it("sorts inprogress items to the top of each group", () => { + const todoData: TodoData = [ + { + title: "A", + visible: true, + todoObjects: [ + createTodoObject(1, "Todo A"), + createTodoObject(1, "Todo B inprogress:1"), + createTodoObject(1, "Todo C"), + ], + }, + ]; + + sortInProgressFirst(todoData); + + expect(todoData[0].todoObjects[0].inprogress).toBe(true); + expect(todoData[0].todoObjects[1].inprogress).toBe(false); + expect(todoData[0].todoObjects[2].inprogress).toBe(false); + }); + + it("sorts all inprogress items first across multiple groups", () => { + const todoData: TodoData = [ + { + title: "Group A", + visible: true, + todoObjects: [ + createTodoObject(1, "Todo 1"), + createTodoObject(1, "Todo 2 inprogress:1"), + ], + }, + { + title: "Group B", + visible: true, + todoObjects: [ + createTodoObject(1, "Todo 3 inprogress:1"), + createTodoObject(1, "Todo 4"), + createTodoObject(1, "Todo 5 inprogress:1"), + ], + }, + ]; + + sortInProgressFirst(todoData); + + expect(todoData[0].todoObjects[0].inprogress).toBe(true); + expect(todoData[0].todoObjects[1].inprogress).toBe(false); + + expect(todoData[1].todoObjects[0].inprogress).toBe(true); + expect(todoData[1].todoObjects[1].inprogress).toBe(true); + expect(todoData[1].todoObjects[2].inprogress).toBe(false); + }); + + it("preserves relative order of non-inprogress items", () => { + const todoData: TodoData = [ + { + title: null, + visible: true, + todoObjects: [ + createTodoObject(1, "Todo 1"), + createTodoObject(1, "Todo 2"), + createTodoObject(1, "Todo 3 inprogress:1"), + createTodoObject(1, "Todo 4"), + ], + }, + ]; + + sortInProgressFirst(todoData); + + expect(todoData[0].todoObjects[0].body).toBe("Todo 3 inprogress:1"); + expect(todoData[0].todoObjects[1].body).toBe("Todo 1"); + expect(todoData[0].todoObjects[2].body).toBe("Todo 2"); + expect(todoData[0].todoObjects[3].body).toBe("Todo 4"); + }); +}); diff --git a/src/main/DataRequest/Sort.ts b/src/main/DataRequest/Sort.ts index e40604ef..fa5de154 100644 --- a/src/main/DataRequest/Sort.ts +++ b/src/main/DataRequest/Sort.ts @@ -1,4 +1,4 @@ -import { TodoObject } from "@sleek-types"; +import { TodoData, TodoObject } from "@sleek-types"; import { Sorting } from "../../@types/Settings"; import { RecurrenceInterval, intervalDayWeight } from "./RecurrenceInterval"; @@ -106,4 +106,14 @@ const sortTodoObjects = ( return 0; }; -export { sortTodoObjects, parseRecurrenceSortKey }; +const sortInProgressFirst = (todoData: TodoData): void => { + for (const group of todoData) { + group.todoObjects.sort((a, b) => { + if (a.inprogress && !b.inprogress) return -1; + if (!a.inprogress && b.inprogress) return 1; + return 0; + }); + } +}; + +export { sortTodoObjects, parseRecurrenceSortKey, sortInProgressFirst }; diff --git a/src/renderer/Grid/Renderer.tsx b/src/renderer/Grid/Renderer.tsx index 27b495da..1242e8a9 100644 --- a/src/renderer/Grid/Renderer.tsx +++ b/src/renderer/Grid/Renderer.tsx @@ -52,6 +52,11 @@ const RendererComponent: React.FC = memo( { pattern: /(?:^|\s)@(\S+)/, type: "contexts", key: "@" }, { pattern: /(?:^|\s)\+(\S+)/, type: "projects", key: "+" }, { pattern: /(?:^|\s)h:1\b/, type: "hidden", key: "h:1" }, + { + pattern: /(?:^|\s)inprogress:1/, + type: "inprogress", + key: "inprogress:1", + }, { pattern: /(?:^|\s)pm:(\d+)/, type: "pm", key: "pm:" }, { pattern: /(?:^|\s)rec:([^ ]+)/, type: "rec", key: "rec:" }, { @@ -217,6 +222,7 @@ const RendererComponent: React.FC = memo( ), hidden: () => null as React.ReactNode, + inprogress: () => null as React.ReactNode, url: (value) => ( ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + changeLanguage: vi.fn(), + }, + }), +})); + +const mockIpcRendererSend = vi.spyOn(window.api.ipcRenderer, "send"); + +const baseTodo: TodoObject = { + body: "Test todo", + lineNumber: 1, + complete: false, + created: null, + completed: null, + contexts: null, + projects: null, + priority: null, + hidden: false, + inprogress: false, + due: null, + dueString: null, + t: null, + tString: null, + rec: null, + pm: null, + string: "Test todo", + notify: false, +}; + +const baseSettings: SettingStore = { + appendCreationDate: false, + convertRelativeToAbsoluteDates: false, + useHumanFriendlyDates: false, + bulkTodoCreation: false, + weekStart: 1, + language: "en", + tray: false, + invertTrayColor: false, + startMinimized: false, + notificationsAllowed: false, + notificationThreshold: 0, + colorTheme: "system", + menuBarVisibility: false, + disableAnimations: false, + compact: false, + zoom: 1, + matomo: false, + showCompleted: false, + showHidden: false, + showAttributesFromHiddenTodosInDrawer: true, + fileSorting: false, + sortCompletedLast: false, + dueDateInTheFuture: false, + files: [], + sorting: [{ invert: false, value: "priority" }], + windowDimensions: null, + windowMaximized: false, + windowPosition: null, + isDrawerOpen: false, + isNavigationOpen: false, + isSearchOpen: false, + accordionOpenState: [], + showFileTabs: false, + shouldUseDarkColors: false, + customStylesPath: "", + excludeLinesWithPrefix: [], + chokidarOptions: {}, + __internal__: { + migrations: { + version: "", + }, + }, + channel: "", + anonymousUserId: "", +}; + +const baseFilters: Filters = { + priority: [], + due: [], + t: [], + contexts: [], + projects: [], + rec: [], + inprogress: [], + pm: [], + hidden: [], + created: [], + completed: [], +}; + +const renderRow = (todo: Partial = {}) => { + const todoObject = { ...baseTodo, ...todo }; + return render( + {}} + setTodoObject={() => {}} + setContextMenu={() => {}} + setPromptItem={() => {}} + settings={baseSettings} + onNotification={vi.fn()} + />, + ); +}; + +describe("Row", () => { + beforeEach(() => { + mockIpcRendererSend.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should send complete toggle on regular checkbox click", () => { + renderRow(); + const checkbox = screen.getByRole("checkbox"); + fireEvent.click(checkbox); + + expect(mockIpcRendererSend).toHaveBeenCalledWith( + "toggleTodoComplete", + 1, + "Test todo", + true, + ); + }); + + it("should send inprogress toggle and NOT complete toggle on ctrl+click", () => { + renderRow(); + const checkbox = screen.getByRole("checkbox"); + fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); + fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); + + expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); + expect(mockIpcRendererSend).toHaveBeenCalledWith( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "1", + true, + ); + }); + + it("should send inprogress toggle and NOT complete toggle on middle-click", () => { + renderRow(); + const checkbox = screen.getByRole("checkbox"); + fireEvent.mouseDown(checkbox, { button: 1 }); + fireEvent.click(checkbox, { button: 1 }); + + expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); + expect(mockIpcRendererSend).toHaveBeenCalledWith( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "1", + true, + ); + }); + + it("should toggle off inprogress when todo is already inprogress", () => { + renderRow({ inprogress: true }); + const checkbox = screen.getByRole("checkbox"); + fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); + fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); + + expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); + expect(mockIpcRendererSend).toHaveBeenCalledWith( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "", + true, + ); + }); + + it("should not toggle inprogress when todo is already complete", () => { + renderRow({ complete: true }); + const checkbox = screen.getByRole("checkbox"); + fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); + fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); + + expect(mockIpcRendererSend).not.toHaveBeenCalled(); + }); + + it("should never mark as done on repeated ctrl+clicks", () => { + const { rerender } = renderRow(); + const checkbox = screen.getByRole("checkbox"); + + fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); + fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); + expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); + expect(mockIpcRendererSend).toHaveBeenLastCalledWith( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "1", + true, + ); + + mockIpcRendererSend.mockClear(); + + rerender( + {}} + setTodoObject={() => {}} + setContextMenu={() => {}} + setPromptItem={() => {}} + settings={baseSettings} + onNotification={vi.fn()} + />, + ); + + fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); + fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); + expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); + expect(mockIpcRendererSend).toHaveBeenLastCalledWith( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "", + true, + ); + + mockIpcRendererSend.mockClear(); + + rerender( + {}} + setTodoObject={() => {}} + setContextMenu={() => {}} + setPromptItem={() => {}} + settings={baseSettings} + onNotification={vi.fn()} + />, + ); + + fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); + fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); + expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); + expect(mockIpcRendererSend).toHaveBeenLastCalledWith( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "1", + true, + ); + + for (const call of mockIpcRendererSend.mock.calls) { + expect(call[0]).not.toBe("toggleTodoComplete"); + } + }); +}); diff --git a/src/renderer/Grid/Row.tsx b/src/renderer/Grid/Row.tsx index db12d4f7..8626bfdb 100644 --- a/src/renderer/Grid/Row.tsx +++ b/src/renderer/Grid/Row.tsx @@ -61,6 +61,7 @@ const Row: React.FC = memo( t, ipcRenderer, }); + const skipNextOnChange = React.useRef(false); const handleConfirmDelete = (): void => { if (todoObject) @@ -74,6 +75,10 @@ const Row: React.FC = memo( const handleCheckboxChange = ( event: React.ChangeEvent, ): void => { + if (skipNextOnChange.current) { + skipNextOnChange.current = false; + return; + } ipcRenderer.send( "toggleTodoComplete", todoObject.lineNumber, @@ -200,10 +205,23 @@ const Row: React.FC = memo( checked={todoObject.complete} indeterminate={todoObject.inprogress} onChange={handleCheckboxChange} + slotProps={{ + input: { + onClick: (event: React.MouseEvent) => { + if (event.ctrlKey) { + event.preventDefault(); + event.stopPropagation(); + } + }, + }, + }} onMouseDown={(event) => { if (event.button === 1 || event.ctrlKey) { event.preventDefault(); + skipNextOnChange.current = true; handleInprogressToggle(); + } else { + skipNextOnChange.current = false; } }} /> From fcca84412c7206566cf89e59db1c2222ed45ff15 Mon Sep 17 00:00:00 2001 From: wallek Date: Thu, 21 May 2026 10:59:54 +0200 Subject: [PATCH 3/4] refactor: extract sortCompletedLast into separate function - Extract inline completed items sorting logic into sortCompletedLast function - Add unit tests for sortCompletedLast function - Remove unused event parameter from handleInprogressToggle --- src/main/Attributes.ts | 1 + src/main/DataRequest/DataRequest.ts | 19 +++----- src/main/DataRequest/Sort.test.ts | 76 +++++++++++++++++++++++++++++ src/main/DataRequest/Sort.ts | 17 ++++++- 4 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/main/Attributes.ts b/src/main/Attributes.ts index 84fcd023..1d0df262 100644 --- a/src/main/Attributes.ts +++ b/src/main/Attributes.ts @@ -29,6 +29,7 @@ let attributes: Attributes = { rec: {}, pm: {}, hidden: {}, + inprogress: {}, created: {}, completed: {}, }; diff --git a/src/main/DataRequest/DataRequest.ts b/src/main/DataRequest/DataRequest.ts index 248f29bf..adbb179c 100644 --- a/src/main/DataRequest/DataRequest.ts +++ b/src/main/DataRequest/DataRequest.ts @@ -10,7 +10,11 @@ import { } from "../Filters/Filters"; import { updateAttributes, attributes } from "../Attributes"; import { createTodoObjects } from "./CreateTodoObjects"; -import { sortTodoObjects, sortInProgressFirst } from "./Sort"; +import { + sortTodoObjects, + sortInProgressFirst, + sortCompletedLast, +} from "./Sort"; import { groupTodoObjects } from "./Group"; import { RequestedData, @@ -49,6 +53,7 @@ function dataRequest(passedSearchString: string = ""): RequestedData | null { rec: {}, pm: {}, hidden: {}, + inprogress: {}, created: {}, completed: {}, }, @@ -62,6 +67,7 @@ function dataRequest(passedSearchString: string = ""): RequestedData | null { rec: [], pm: [], hidden: [], + inprogress: [], created: [], completed: [], }, @@ -142,16 +148,7 @@ function dataRequest(passedSearchString: string = ""): RequestedData | null { todoObjects.sort((a, b) => sortTodoObjects(a, b, sorting)); todoData = groupTodoObjects(todoObjects, sorting[0].value); - const sortCompletedLast = SettingsStore.get("sortCompletedLast"); - if (sortCompletedLast) { - for (const group of todoData) { - group.todoObjects.sort((a, b) => { - if (a.complete && !b.complete) return 1; - if (!a.complete && b.complete) return -1; - return 0; - }); - } - } + if (SettingsStore.get("sortCompletedLast")) sortCompletedLast(todoData); sortInProgressFirst(todoData); } diff --git a/src/main/DataRequest/Sort.test.ts b/src/main/DataRequest/Sort.test.ts index c6f24194..509fc450 100644 --- a/src/main/DataRequest/Sort.test.ts +++ b/src/main/DataRequest/Sort.test.ts @@ -3,6 +3,7 @@ import { sortTodoObjects, parseRecurrenceSortKey, sortInProgressFirst, + sortCompletedLast, } from "./Sort"; import { createTodoObject } from "./CreateTodoObjects"; import { TodoData } from "@sleek-types"; @@ -348,3 +349,78 @@ describe("sortInProgressFirst", () => { expect(todoData[0].todoObjects[3].body).toBe("Todo 4"); }); }); + +describe("sortCompletedLast", () => { + it("sorts completed items to the bottom of each group", () => { + const todoData: TodoData = [ + { + title: "A", + visible: true, + todoObjects: [ + createTodoObject(1, "x Todo A"), + createTodoObject(1, "Todo B"), + createTodoObject(1, "x Todo C"), + ], + }, + ]; + + sortCompletedLast(todoData); + + expect(todoData[0].todoObjects[0].complete).toBe(false); + expect(todoData[0].todoObjects[1].complete).toBe(true); + expect(todoData[0].todoObjects[2].complete).toBe(true); + }); + + it("sorts completed items last across multiple groups", () => { + const todoData: TodoData = [ + { + title: "Group A", + visible: true, + todoObjects: [ + createTodoObject(1, "x Todo 1"), + createTodoObject(1, "Todo 2"), + ], + }, + { + title: "Group B", + visible: true, + todoObjects: [ + createTodoObject(1, "Todo 3"), + createTodoObject(1, "x Todo 4"), + createTodoObject(1, "Todo 5"), + ], + }, + ]; + + sortCompletedLast(todoData); + + expect(todoData[0].todoObjects[0].complete).toBe(false); + expect(todoData[0].todoObjects[1].complete).toBe(true); + + expect(todoData[1].todoObjects[0].complete).toBe(false); + expect(todoData[1].todoObjects[1].complete).toBe(false); + expect(todoData[1].todoObjects[2].complete).toBe(true); + }); + + it("preserves relative order of non-completed items", () => { + const todoData: TodoData = [ + { + title: null, + visible: true, + todoObjects: [ + createTodoObject(1, "Todo 1"), + createTodoObject(1, "x Todo 2"), + createTodoObject(1, "Todo 3"), + createTodoObject(1, "Todo 4"), + ], + }, + ]; + + sortCompletedLast(todoData); + + expect(todoData[0].todoObjects[0].string).toBe("Todo 1"); + expect(todoData[0].todoObjects[1].string).toBe("Todo 3"); + expect(todoData[0].todoObjects[2].string).toBe("Todo 4"); + expect(todoData[0].todoObjects[3].string).toBe("x Todo 2"); + }); +}); diff --git a/src/main/DataRequest/Sort.ts b/src/main/DataRequest/Sort.ts index fa5de154..c3815c2e 100644 --- a/src/main/DataRequest/Sort.ts +++ b/src/main/DataRequest/Sort.ts @@ -116,4 +116,19 @@ const sortInProgressFirst = (todoData: TodoData): void => { } }; -export { sortTodoObjects, parseRecurrenceSortKey, sortInProgressFirst }; +const sortCompletedLast = (todoData: TodoData): void => { + for (const group of todoData) { + group.todoObjects.sort((a, b) => { + if (a.complete && !b.complete) return 1; + if (!a.complete && b.complete) return -1; + return 0; + }); + } +}; + +export { + sortTodoObjects, + parseRecurrenceSortKey, + sortInProgressFirst, + sortCompletedLast, +}; From 6ebfd9088e8a8a8862c1f40c2d479c954d7ab689 Mon Sep 17 00:00:00 2001 From: wallek Date: Thu, 15 Oct 2026 14:13:01 +0200 Subject: [PATCH 4/4] refactor(inprogress): integrate in-progress state into existing attribute handling - Store inprogress as "1" | null like other attribute values - Replace ctrl+click with a row context menu item (with middle-click hint); keep middle-click - Show IncompleteCircle icon with "In progress" tooltip and aria-label - Make inprogress a regular sorting entry (migration 2.0.30); inverting sorts in-progress todos last - Label in-progress in the sidebar chip and group header; allow removing it via the attribute menu - Inline sortCompletedLast again --- src/@types/Todo.ts | 2 +- src/@types/UI.ts | 1 + src/locales/en.json | 4 + src/main/Attributes.test.ts | 2 +- .../DataRequest/AttributeModifier.test.ts | 13 ++ src/main/DataRequest/AttributeModifier.ts | 10 ++ .../DataRequest/CreateTodoObjects.test.ts | 11 ++ src/main/DataRequest/CreateTodoObjects.ts | 4 +- src/main/DataRequest/DataRequest.ts | 19 +- src/main/DataRequest/Sort.test.ts | 163 ++---------------- src/main/DataRequest/Sort.ts | 33 +--- src/main/Stores.ts | 10 ++ src/renderer/ContextMenu.tsx | 13 +- src/renderer/Drawer/Attributes.tsx | 8 +- src/renderer/Grid/Group.tsx | 6 +- src/renderer/Grid/Renderer.tsx | 2 +- src/renderer/Grid/Row.scss | 2 +- src/renderer/Grid/Row.test.tsx | 139 ++++++--------- src/renderer/Grid/Row.tsx | 85 ++++----- src/renderer/Shared.tsx | 1 + src/renderer/Shared/AttributeContextMenu.ts | 1 + src/renderer/Shared/useRowContextMenu.ts | 36 +++- 22 files changed, 220 insertions(+), 345 deletions(-) create mode 100644 src/main/DataRequest/AttributeModifier.test.ts diff --git a/src/@types/Todo.ts b/src/@types/Todo.ts index b35e402d..710b5491 100644 --- a/src/@types/Todo.ts +++ b/src/@types/Todo.ts @@ -14,7 +14,7 @@ export interface TodoObject { priority: string | null; hidden: boolean; - inprogress: boolean; + inprogress: string | null; due: string | null; dueString: string | null; t: string | null; diff --git a/src/@types/UI.ts b/src/@types/UI.ts index 8abddc39..494633fd 100644 --- a/src/@types/UI.ts +++ b/src/@types/UI.ts @@ -20,6 +20,7 @@ export interface PromptItem { export interface ContextMenuItem { id: string; label: string; + hint?: string; promptItem?: PromptItem; function?: () => void; } diff --git a/src/locales/en.json b/src/locales/en.json index 538ea016..ae73ae93 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -7,6 +7,9 @@ "remove": "Remove", "add": "Add", "archive": "Archive", + "markInProgress": "Mark in progress", + "removeInProgress": "Remove in progress", + "middleClick": "Middle-click", "settings": "Settings", "close": "Close", "attributes": "Attributes", @@ -80,6 +83,7 @@ "shared.attributeMapping.pm": "Pomodoros", "shared.attributeMapping.created": "Creation date", "shared.attributeMapping.completed": "Completion date", + "shared.attributeMapping.inprogress": "In progress", "splashscreen.noFiles.text": "Open an existing todo.txt file or create a new one", "splashscreen.noTodosAvailable.text": "No todos in this file", "splashscreen.noTodosAvailable.create": "Create a todo", diff --git a/src/main/Attributes.test.ts b/src/main/Attributes.test.ts index a50c31b1..07ccdea3 100644 --- a/src/main/Attributes.test.ts +++ b/src/main/Attributes.test.ts @@ -41,7 +41,7 @@ function todo(overrides: Partial = {}): TodoObject { notify: false, hidden: false, complete: false, - inprogress: false, + inprogress: null, lineNumber: 1, body: "", string: "", diff --git a/src/main/DataRequest/AttributeModifier.test.ts b/src/main/DataRequest/AttributeModifier.test.ts new file mode 100644 index 00000000..fe35be26 --- /dev/null +++ b/src/main/DataRequest/AttributeModifier.test.ts @@ -0,0 +1,13 @@ +import { expect, describe, it } from "vitest"; +import { deleteAttributeValue } from "./AttributeModifier"; + +describe("deleteAttributeValue", () => { + it("removes inprogress regardless of the passed value", () => { + const lines = ["Todo 1 inprogress:1", "Todo 2"]; + + const { count } = deleteAttributeValue(lines, "inprogress", "In progress"); + + expect(count).toBe(1); + expect(lines).toEqual(["Todo 1", "Todo 2"]); + }); +}); diff --git a/src/main/DataRequest/AttributeModifier.ts b/src/main/DataRequest/AttributeModifier.ts index 4516235b..4ce93cd4 100644 --- a/src/main/DataRequest/AttributeModifier.ts +++ b/src/main/DataRequest/AttributeModifier.ts @@ -162,6 +162,16 @@ export function deleteAttributeValue( } break; } + case "inprogress": { + // A flag, removed regardless of the value, which the sidebar passes as its label. + const item = new Item(newLine); + if (item.extensions().some((e) => e.key === "inprogress")) { + item.removeExtension("inprogress"); + newLine = item.toString(); + modified = true; + } + break; + } case "created": case "completed": { const item = new Item(newLine); diff --git a/src/main/DataRequest/CreateTodoObjects.test.ts b/src/main/DataRequest/CreateTodoObjects.test.ts index 6b910290..c88a2976 100644 --- a/src/main/DataRequest/CreateTodoObjects.test.ts +++ b/src/main/DataRequest/CreateTodoObjects.test.ts @@ -66,6 +66,17 @@ describe("createTodoObject", () => { }); }); + describe("create in-progress todo", () => { + it("sets inprogress to 1", () => { + const todo = createTodoObject(1, "I am in progress inprogress:1"); + expect(todo.inprogress).toBe("1"); + }); + it("sets inprogress to null for other values", () => { + const todo = createTodoObject(1, "I am in progress inprogress:2"); + expect(todo.inprogress).toBeNull(); + }); + }); + describe("create todo with updates", () => { it("updates priority", () => { const todo = createTodoObject(1, "(B) todo", "priority", "A"); diff --git a/src/main/DataRequest/CreateTodoObjects.ts b/src/main/DataRequest/CreateTodoObjects.ts index 16cb01cf..2387a09c 100644 --- a/src/main/DataRequest/CreateTodoObjects.ts +++ b/src/main/DataRequest/CreateTodoObjects.ts @@ -63,7 +63,9 @@ function createTodoObject( ); const inprogress = extensions.some( (extension) => extension.key === "inprogress" && extension.value === "1", - ); + ) + ? "1" + : null; const stringPm: string | null = extensions.find((extension) => extension.key === "pm")?.value || null; const rec = diff --git a/src/main/DataRequest/DataRequest.ts b/src/main/DataRequest/DataRequest.ts index adbb179c..49aafadb 100644 --- a/src/main/DataRequest/DataRequest.ts +++ b/src/main/DataRequest/DataRequest.ts @@ -10,11 +10,7 @@ import { } from "../Filters/Filters"; import { updateAttributes, attributes } from "../Attributes"; import { createTodoObjects } from "./CreateTodoObjects"; -import { - sortTodoObjects, - sortInProgressFirst, - sortCompletedLast, -} from "./Sort"; +import { sortTodoObjects } from "./Sort"; import { groupTodoObjects } from "./Group"; import { RequestedData, @@ -148,9 +144,16 @@ function dataRequest(passedSearchString: string = ""): RequestedData | null { todoObjects.sort((a, b) => sortTodoObjects(a, b, sorting)); todoData = groupTodoObjects(todoObjects, sorting[0].value); - if (SettingsStore.get("sortCompletedLast")) sortCompletedLast(todoData); - - sortInProgressFirst(todoData); + const sortCompletedLast = SettingsStore.get("sortCompletedLast"); + if (sortCompletedLast) { + for (const group of todoData) { + group.todoObjects.sort((a, b) => { + if (a.complete && !b.complete) return 1; + if (!a.complete && b.complete) return -1; + return 0; + }); + } + } } const requestedData: RequestedData = { diff --git a/src/main/DataRequest/Sort.test.ts b/src/main/DataRequest/Sort.test.ts index 509fc450..8e1ed3a9 100644 --- a/src/main/DataRequest/Sort.test.ts +++ b/src/main/DataRequest/Sort.test.ts @@ -1,12 +1,6 @@ import { expect, describe, it, vi } from "vitest"; -import { - sortTodoObjects, - parseRecurrenceSortKey, - sortInProgressFirst, - sortCompletedLast, -} from "./Sort"; +import { sortTodoObjects, parseRecurrenceSortKey } from "./Sort"; import { createTodoObject } from "./CreateTodoObjects"; -import { TodoData } from "@sleek-types"; vi.mock("../Shared", () => ({ lineBreakPlaceholder: "[LB]", @@ -275,152 +269,15 @@ describe("rec sorting", () => { }); }); -describe("sortInProgressFirst", () => { - it("sorts inprogress items to the top of each group", () => { - const todoData: TodoData = [ - { - title: "A", - visible: true, - todoObjects: [ - createTodoObject(1, "Todo A"), - createTodoObject(1, "Todo B inprogress:1"), - createTodoObject(1, "Todo C"), - ], - }, +describe("inprogress sorting", () => { + it("sorts in-progress todos last when inverted", () => { + const todos = [ + createTodoObject(1, "Todo 1 inprogress:1"), + createTodoObject(2, "Todo 2"), ]; - - sortInProgressFirst(todoData); - - expect(todoData[0].todoObjects[0].inprogress).toBe(true); - expect(todoData[0].todoObjects[1].inprogress).toBe(false); - expect(todoData[0].todoObjects[2].inprogress).toBe(false); - }); - - it("sorts all inprogress items first across multiple groups", () => { - const todoData: TodoData = [ - { - title: "Group A", - visible: true, - todoObjects: [ - createTodoObject(1, "Todo 1"), - createTodoObject(1, "Todo 2 inprogress:1"), - ], - }, - { - title: "Group B", - visible: true, - todoObjects: [ - createTodoObject(1, "Todo 3 inprogress:1"), - createTodoObject(1, "Todo 4"), - createTodoObject(1, "Todo 5 inprogress:1"), - ], - }, - ]; - - sortInProgressFirst(todoData); - - expect(todoData[0].todoObjects[0].inprogress).toBe(true); - expect(todoData[0].todoObjects[1].inprogress).toBe(false); - - expect(todoData[1].todoObjects[0].inprogress).toBe(true); - expect(todoData[1].todoObjects[1].inprogress).toBe(true); - expect(todoData[1].todoObjects[2].inprogress).toBe(false); - }); - - it("preserves relative order of non-inprogress items", () => { - const todoData: TodoData = [ - { - title: null, - visible: true, - todoObjects: [ - createTodoObject(1, "Todo 1"), - createTodoObject(1, "Todo 2"), - createTodoObject(1, "Todo 3 inprogress:1"), - createTodoObject(1, "Todo 4"), - ], - }, - ]; - - sortInProgressFirst(todoData); - - expect(todoData[0].todoObjects[0].body).toBe("Todo 3 inprogress:1"); - expect(todoData[0].todoObjects[1].body).toBe("Todo 1"); - expect(todoData[0].todoObjects[2].body).toBe("Todo 2"); - expect(todoData[0].todoObjects[3].body).toBe("Todo 4"); - }); -}); - -describe("sortCompletedLast", () => { - it("sorts completed items to the bottom of each group", () => { - const todoData: TodoData = [ - { - title: "A", - visible: true, - todoObjects: [ - createTodoObject(1, "x Todo A"), - createTodoObject(1, "Todo B"), - createTodoObject(1, "x Todo C"), - ], - }, - ]; - - sortCompletedLast(todoData); - - expect(todoData[0].todoObjects[0].complete).toBe(false); - expect(todoData[0].todoObjects[1].complete).toBe(true); - expect(todoData[0].todoObjects[2].complete).toBe(true); - }); - - it("sorts completed items last across multiple groups", () => { - const todoData: TodoData = [ - { - title: "Group A", - visible: true, - todoObjects: [ - createTodoObject(1, "x Todo 1"), - createTodoObject(1, "Todo 2"), - ], - }, - { - title: "Group B", - visible: true, - todoObjects: [ - createTodoObject(1, "Todo 3"), - createTodoObject(1, "x Todo 4"), - createTodoObject(1, "Todo 5"), - ], - }, - ]; - - sortCompletedLast(todoData); - - expect(todoData[0].todoObjects[0].complete).toBe(false); - expect(todoData[0].todoObjects[1].complete).toBe(true); - - expect(todoData[1].todoObjects[0].complete).toBe(false); - expect(todoData[1].todoObjects[1].complete).toBe(false); - expect(todoData[1].todoObjects[2].complete).toBe(true); - }); - - it("preserves relative order of non-completed items", () => { - const todoData: TodoData = [ - { - title: null, - visible: true, - todoObjects: [ - createTodoObject(1, "Todo 1"), - createTodoObject(1, "x Todo 2"), - createTodoObject(1, "Todo 3"), - createTodoObject(1, "Todo 4"), - ], - }, - ]; - - sortCompletedLast(todoData); - - expect(todoData[0].todoObjects[0].string).toBe("Todo 1"); - expect(todoData[0].todoObjects[1].string).toBe("Todo 3"); - expect(todoData[0].todoObjects[2].string).toBe("Todo 4"); - expect(todoData[0].todoObjects[3].string).toBe("x Todo 2"); + todos.sort((a, b) => + sortTodoObjects(a, b, [{ value: "inprogress", invert: true }]), + ); + expect(todos.map((todo) => todo.lineNumber)).toEqual([2, 1]); }); }); diff --git a/src/main/DataRequest/Sort.ts b/src/main/DataRequest/Sort.ts index c3815c2e..f63bc3a0 100644 --- a/src/main/DataRequest/Sort.ts +++ b/src/main/DataRequest/Sort.ts @@ -1,4 +1,4 @@ -import { TodoData, TodoObject } from "@sleek-types"; +import { TodoObject } from "@sleek-types"; import { Sorting } from "../../@types/Settings"; import { RecurrenceInterval, intervalDayWeight } from "./RecurrenceInterval"; @@ -95,6 +95,10 @@ const sortTodoObjects = ( } else { compareResult = invert ? keyB - keyA : keyA - keyB; } + } else if (value === "inprogress") { + // A flag has only one value to order by, so invert moves in-progress todos last instead + compareResult = + compareValues(a.inprogress, b.inprogress, false) * (invert ? -1 : 1); } else { compareResult = compareValues(a[value], b[value], invert); } @@ -106,29 +110,4 @@ const sortTodoObjects = ( return 0; }; -const sortInProgressFirst = (todoData: TodoData): void => { - for (const group of todoData) { - group.todoObjects.sort((a, b) => { - if (a.inprogress && !b.inprogress) return -1; - if (!a.inprogress && b.inprogress) return 1; - return 0; - }); - } -}; - -const sortCompletedLast = (todoData: TodoData): void => { - for (const group of todoData) { - group.todoObjects.sort((a, b) => { - if (a.complete && !b.complete) return 1; - if (!a.complete && b.complete) return -1; - return 0; - }); - } -}; - -export { - sortTodoObjects, - parseRecurrenceSortKey, - sortInProgressFirst, - sortCompletedLast, -}; +export { sortTodoObjects, parseRecurrenceSortKey }; diff --git a/src/main/Stores.ts b/src/main/Stores.ts index cafbe7c0..0a7d88ae 100644 --- a/src/main/Stores.ts +++ b/src/main/Stores.ts @@ -159,6 +159,16 @@ const migrations = { if (config.get("welcomeScreenShown") === undefined) { config.set("welcomeScreenShown", false); } + + const sorting = config.get("sorting"); + config.set("sorting", [ + ...sorting, + { id: String(sorting.length + 1), value: "inprogress", invert: false }, + ]); + config.set("accordionOpenState", [ + ...config.get("accordionOpenState"), + false, + ]); }, }; diff --git a/src/renderer/ContextMenu.tsx b/src/renderer/ContextMenu.tsx index 02ff86d8..cbe4b263 100644 --- a/src/renderer/ContextMenu.tsx +++ b/src/renderer/ContextMenu.tsx @@ -1,6 +1,8 @@ import React, { memo, useRef, useEffect } from "react"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; +import ListItemText from "@mui/material/ListItemText"; +import Typography from "@mui/material/Typography"; import "./ContextMenu.scss"; import { ContextMenu, ContextMenuItem, PromptItem } from "@sleek-types"; @@ -58,7 +60,16 @@ const ContextMenuComponent: React.FC = memo( data-testid={`contextMenu-item-${contextMenuItem.id}`} onClick={() => onClick(contextMenuItem)} > - {contextMenuItem.label} + {contextMenuItem.label} + {contextMenuItem.hint && ( + + {contextMenuItem.hint} + + )} ))} diff --git a/src/renderer/Drawer/Attributes.tsx b/src/renderer/Drawer/Attributes.tsx index ae4fe277..f1ce39f6 100644 --- a/src/renderer/Drawer/Attributes.tsx +++ b/src/renderer/Drawer/Attributes.tsx @@ -68,9 +68,11 @@ function buildDisplayBuckets( for (const [rawKey, entry] of Object.entries(rawGroup)) { const bucketNames = - settings.useHumanFriendlyDates && isDate - ? friendlyDate(rawKey, attributeKey, settings, t) - : [rawKey]; + attributeKey === "inprogress" + ? [t("shared.attributeMapping.inprogress")] + : settings.useHumanFriendlyDates && isDate + ? friendlyDate(rawKey, attributeKey, settings, t) + : [rawKey]; for (const bucketName of bucketNames) { if (!buckets[bucketName]) { diff --git a/src/renderer/Grid/Group.tsx b/src/renderer/Grid/Group.tsx index 5891c7f8..56add70f 100644 --- a/src/renderer/Grid/Group.tsx +++ b/src/renderer/Grid/Group.tsx @@ -1,6 +1,7 @@ import React, { memo } from "react"; import ListItem from "@mui/material/ListItem"; import Divider from "@mui/material/Divider"; +import { useTranslation } from "react-i18next"; import { HandleFilterSelect, IsSelected } from "../Shared"; import { AttributeKey, Filters, SnackbarAction } from "@sleek-types"; import { useAttributeContextMenu } from "../Shared/useAttributeContextMenu"; @@ -24,6 +25,7 @@ const Group: React.FC = memo( settings, onNotification, }) => { + const { t } = useTranslation(); const { handleContextMenu } = useAttributeContextMenu({ setContextMenu, settings, @@ -68,7 +70,9 @@ const Group: React.FC = memo( onContextMenu={(e) => handleContextMenu(e, value, attributeKey)} data-testid={`datagrid-group-button-${attributeKey}`} > - {value} + {attributeKey === "inprogress" + ? t("shared.attributeMapping.inprogress") + : value} ); diff --git a/src/renderer/Grid/Renderer.tsx b/src/renderer/Grid/Renderer.tsx index 1242e8a9..64947d52 100644 --- a/src/renderer/Grid/Renderer.tsx +++ b/src/renderer/Grid/Renderer.tsx @@ -53,7 +53,7 @@ const RendererComponent: React.FC = memo( { pattern: /(?:^|\s)\+(\S+)/, type: "projects", key: "+" }, { pattern: /(?:^|\s)h:1\b/, type: "hidden", key: "h:1" }, { - pattern: /(?:^|\s)inprogress:1/, + pattern: /(?:^|\s)inprogress:1\b/, type: "inprogress", key: "inprogress:1", }, diff --git a/src/renderer/Grid/Row.scss b/src/renderer/Grid/Row.scss index 47c8fb94..5b8bea66 100644 --- a/src/renderer/Grid/Row.scss +++ b/src/renderer/Grid/Row.scss @@ -30,7 +30,7 @@ &[data-hidden="true"] { filter: opacity(50%) grayscale(100%); } - &[data-inprogress="true"] { + &[data-inprogress="1"] { border-left-color: var(--semantic-warning); } &:last-child { diff --git a/src/renderer/Grid/Row.test.tsx b/src/renderer/Grid/Row.test.tsx index d9a43c30..bca3a0ea 100644 --- a/src/renderer/Grid/Row.test.tsx +++ b/src/renderer/Grid/Row.test.tsx @@ -1,7 +1,12 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import Row from "./Row"; -import { TodoObject, Filters, SettingStore } from "@sleek-types"; +import { + TodoObject, + Filters, + SettingStore, + ContextMenuItem, +} from "@sleek-types"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -24,7 +29,7 @@ const baseTodo: TodoObject = { projects: null, priority: null, hidden: false, - inprogress: false, + inprogress: null, due: null, dueString: null, t: null, @@ -96,7 +101,10 @@ const baseFilters: Filters = { completed: [], }; -const renderRow = (todo: Partial = {}) => { +const renderRow = ( + todo: Partial = {}, + setContextMenu: () => void = () => {}, +) => { const todoObject = { ...baseTodo, ...todo }; return render( = {}) => { filters={baseFilters} setDialogOpen={() => {}} setTodoObject={() => {}} - setContextMenu={() => {}} + setContextMenu={setContextMenu} setPromptItem={() => {}} settings={baseSettings} onNotification={vi.fn()} @@ -112,6 +120,13 @@ const renderRow = (todo: Partial = {}) => { ); }; +const openRowContextMenu = (todo: Partial): ContextMenuItem[] => { + const setContextMenu = vi.fn(); + renderRow(todo, setContextMenu); + fireEvent.contextMenu(screen.getByTestId("datagrid-row")); + return setContextMenu.mock.calls[0][0].items; +}; + describe("Row", () => { beforeEach(() => { mockIpcRendererSend.mockClear(); @@ -134,28 +149,9 @@ describe("Row", () => { ); }); - it("should send inprogress toggle and NOT complete toggle on ctrl+click", () => { - renderRow(); - const checkbox = screen.getByRole("checkbox"); - fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); - fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); - - expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); - expect(mockIpcRendererSend).toHaveBeenCalledWith( - "updateTodoObject", - 1, - "Test todo", - "inprogress", - "1", - true, - ); - }); - - it("should send inprogress toggle and NOT complete toggle on middle-click", () => { + it("should send inprogress toggle on middle-click", () => { renderRow(); - const checkbox = screen.getByRole("checkbox"); - fireEvent.mouseDown(checkbox, { button: 1 }); - fireEvent.click(checkbox, { button: 1 }); + fireEvent.mouseDown(screen.getByRole("checkbox"), { button: 1 }); expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); expect(mockIpcRendererSend).toHaveBeenCalledWith( @@ -168,11 +164,9 @@ describe("Row", () => { ); }); - it("should toggle off inprogress when todo is already inprogress", () => { - renderRow({ inprogress: true }); - const checkbox = screen.getByRole("checkbox"); - fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); - fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); + it("should toggle off inprogress on middle-click when todo is already inprogress", () => { + renderRow({ inprogress: "1" }); + fireEvent.mouseDown(screen.getByRole("checkbox"), { button: 1 }); expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); expect(mockIpcRendererSend).toHaveBeenCalledWith( @@ -185,87 +179,60 @@ describe("Row", () => { ); }); - it("should not toggle inprogress when todo is already complete", () => { + it("should not toggle inprogress on middle-click when todo is already complete", () => { renderRow({ complete: true }); - const checkbox = screen.getByRole("checkbox"); - fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); - fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); + fireEvent.mouseDown(screen.getByRole("checkbox"), { button: 1 }); expect(mockIpcRendererSend).not.toHaveBeenCalled(); }); - it("should never mark as done on repeated ctrl+clicks", () => { - const { rerender } = renderRow(); - const checkbox = screen.getByRole("checkbox"); + it("should label the checkbox as in progress when todo is inprogress", () => { + renderRow({ inprogress: "1" }); - fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); - fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); - expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); - expect(mockIpcRendererSend).toHaveBeenLastCalledWith( - "updateTodoObject", - 1, - "Test todo", - "inprogress", - "1", - true, + expect(screen.getByRole("checkbox")).toHaveAccessibleName( + "shared.attributeMapping.inprogress", ); + expect(screen.getByRole("checkbox")).not.toBeChecked(); + }); - mockIpcRendererSend.mockClear(); + it("should offer mark in progress with a middle-click hint in the context menu", () => { + const item = openRowContextMenu({}).find((i) => i.id === "inprogress"); - rerender( - {}} - setTodoObject={() => {}} - setContextMenu={() => {}} - setPromptItem={() => {}} - settings={baseSettings} - onNotification={vi.fn()} - />, - ); + expect(item?.label).toBe("markInProgress"); + expect(item?.hint).toBe("middleClick"); - fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); - fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); - expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); - expect(mockIpcRendererSend).toHaveBeenLastCalledWith( + item?.function?.(); + expect(mockIpcRendererSend).toHaveBeenCalledWith( "updateTodoObject", 1, "Test todo", "inprogress", - "", + "1", true, ); + }); - mockIpcRendererSend.mockClear(); - - rerender( - {}} - setTodoObject={() => {}} - setContextMenu={() => {}} - setPromptItem={() => {}} - settings={baseSettings} - onNotification={vi.fn()} - />, + it("should offer remove in progress in the context menu when todo is inprogress", () => { + const item = openRowContextMenu({ inprogress: "1" }).find( + (i) => i.id === "inprogress", ); - fireEvent.mouseDown(checkbox, { ctrlKey: true, button: 0 }); - fireEvent.click(checkbox, { ctrlKey: true, button: 0 }); - expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); - expect(mockIpcRendererSend).toHaveBeenLastCalledWith( + expect(item?.label).toBe("removeInProgress"); + + item?.function?.(); + expect(mockIpcRendererSend).toHaveBeenCalledWith( "updateTodoObject", 1, "Test todo", "inprogress", - "1", + "", true, ); + }); + + it("should not offer in progress in the context menu when todo is complete", () => { + const items = openRowContextMenu({ complete: true }); - for (const call of mockIpcRendererSend.mock.calls) { - expect(call[0]).not.toBe("toggleTodoComplete"); - } + expect(items.some((i) => i.id === "inprogress")).toBe(false); }); }); diff --git a/src/renderer/Grid/Row.tsx b/src/renderer/Grid/Row.tsx index 8626bfdb..4cc0961b 100644 --- a/src/renderer/Grid/Row.tsx +++ b/src/renderer/Grid/Row.tsx @@ -3,7 +3,7 @@ import Checkbox from "@mui/material/Checkbox"; import Tooltip from "@mui/material/Tooltip"; import CircleChecked from "@mui/icons-material/CheckCircle"; import CircleUnchecked from "@mui/icons-material/RadioButtonUnchecked"; -import IndeterminateCheckBox from "@mui/icons-material/IndeterminateCheckBox"; +import IncompleteCircle from "@mui/icons-material/IncompleteCircle"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import EventAvailableIcon from "@mui/icons-material/EventAvailable"; import EventNoteIcon from "@mui/icons-material/EventNote"; @@ -55,13 +55,12 @@ const Row: React.FC = memo( settings, onNotification, }); - const { handleRowContextMenu } = useRowContextMenu({ + const { handleRowContextMenu, handleToggleInProgress } = useRowContextMenu({ setContextMenu, setPromptItem, t, ipcRenderer, }); - const skipNextOnChange = React.useRef(false); const handleConfirmDelete = (): void => { if (todoObject) @@ -75,10 +74,6 @@ const Row: React.FC = memo( const handleCheckboxChange = ( event: React.ChangeEvent, ): void => { - if (skipNextOnChange.current) { - skipNextOnChange.current = false; - return; - } ipcRenderer.send( "toggleTodoComplete", todoObject.lineNumber, @@ -87,19 +82,6 @@ const Row: React.FC = memo( ); }; - const handleInprogressToggle = (): void => { - if (todoObject.complete) return; - const attributeValue = todoObject.inprogress ? "" : "1"; - ipcRenderer.send( - "updateTodoObject", - todoObject.lineNumber, - todoObject.string, - "inprogress", - attributeValue, - true, - ); - }; - const preventDialog = (clickedElement): boolean => { let match = false; @@ -175,6 +157,10 @@ const Row: React.FC = memo( } }; + const inProgressLabel = todoObject.inprogress + ? t("shared.attributeMapping.inprogress") + : undefined; + return ( <>
  • = memo( data-inprogress={todoObject.inprogress} onClick={(event) => handleRowClick(event)} onKeyDown={(event) => handleRowClick(event)} - onContextMenu={(event) => - handleRowContextMenu( - event, - todoObject.string, - todoObject.lineNumber, - ) - } + onContextMenu={(event) => handleRowContextMenu(event, todoObject)} data-testid={`datagrid-row`} data-todotxt-attribute="priority" data-todotxt-value={todoObject.priority} > - } - checkedIcon={} - indeterminateIcon={} - tabIndex={0} - checked={todoObject.complete} - indeterminate={todoObject.inprogress} - onChange={handleCheckboxChange} - slotProps={{ - input: { - onClick: (event: React.MouseEvent) => { - if (event.ctrlKey) { - event.preventDefault(); - event.stopPropagation(); - } - }, - }, - }} - onMouseDown={(event) => { - if (event.button === 1 || event.ctrlKey) { - event.preventDefault(); - skipNextOnChange.current = true; - handleInprogressToggle(); - } else { - skipNextOnChange.current = false; + + + ) : ( + + ) } - }} - /> + checkedIcon={} + tabIndex={0} + checked={todoObject.complete} + onChange={handleCheckboxChange} + slotProps={{ + input: { "aria-label": inProgressLabel }, + }} + onMouseDown={(event) => { + // middle-click fires auxclick, not click/change + if (event.button === 1 && !todoObject.complete) { + event.preventDefault(); + handleToggleInProgress(todoObject); + } + }} + /> + {(settings.sorting[0].value != "priority" || settings.fileSorting) && todoObject.priority && ( diff --git a/src/renderer/Shared.tsx b/src/renderer/Shared.tsx index 6d2315ff..d602cadd 100644 --- a/src/renderer/Shared.tsx +++ b/src/renderer/Shared.tsx @@ -102,6 +102,7 @@ export const translatedAttributes = ( pm: t("shared.attributeMapping.pm"), created: t("shared.attributeMapping.created"), completed: t("shared.attributeMapping.completed"), + inprogress: t("shared.attributeMapping.inprogress"), }; }; diff --git a/src/renderer/Shared/AttributeContextMenu.ts b/src/renderer/Shared/AttributeContextMenu.ts index ef0f3e83..7f02d42d 100644 --- a/src/renderer/Shared/AttributeContextMenu.ts +++ b/src/renderer/Shared/AttributeContextMenu.ts @@ -19,6 +19,7 @@ export function createAttributeContextMenuItems( // Date and system-managed attributes should only show delete (no rename) // Recurrences support rename (they don't have fuzzy parsing issues) const disableRenameForAttributes = [ + "inprogress", ...(settings?.useHumanFriendlyDates ? ["due", "t"] : []), ]; const shouldDisableRename = diff --git a/src/renderer/Shared/useRowContextMenu.ts b/src/renderer/Shared/useRowContextMenu.ts index ef00f3a0..02d1fb54 100644 --- a/src/renderer/Shared/useRowContextMenu.ts +++ b/src/renderer/Shared/useRowContextMenu.ts @@ -1,6 +1,6 @@ import React from "react"; import { IpcRenderer } from "electron"; -import { AttributeKey, ContextMenuItem, PromptItem } from "@sleek-types"; +import { ContextMenuItem, PromptItem, TodoObject } from "@sleek-types"; type ContextMenuProps = { event: React.MouseEvent; @@ -17,10 +17,9 @@ type UseRowContextMenuProps = { type UseRowContextMenuReturn = { handleRowContextMenu: ( event: React.MouseEvent, - todoString: string, - lineNumber: number, - attributeType?: AttributeKey, + todoObject: TodoObject, ) => void; + handleToggleInProgress: (todoObject: TodoObject) => void; }; export const useRowContextMenu = ( @@ -40,11 +39,22 @@ export const useRowContextMenu = ( ipcRenderer.send("removeLineFromFile", lineNumber); }; + const handleToggleInProgress = (todoObject: TodoObject) => { + ipcRenderer.send( + "updateTodoObject", + todoObject.lineNumber, + todoObject.string, + "inprogress", + todoObject.inprogress ? "" : "1", + true, + ); + }; + const handleRowContextMenu = ( event: React.MouseEvent, - todoString: string, - lineNumber: number, + todoObject: TodoObject, ): void => { + const { string: todoString, lineNumber } = todoObject; // Check if the right-click target is an attribute button const target = event.target as HTMLElement; const isAttributeButton = target.closest( @@ -57,6 +67,18 @@ export const useRowContextMenu = ( } const items: ContextMenuItem[] = [ + ...(todoObject.complete + ? [] + : [ + { + id: "inprogress", + label: todoObject.inprogress + ? t("removeInProgress") + : t("markInProgress"), + hint: t("middleClick"), + function: () => handleToggleInProgress(todoObject), + }, + ]), { id: "copy", label: t("copy"), @@ -86,5 +108,5 @@ export const useRowContextMenu = ( }); }; - return { handleRowContextMenu }; + return { handleRowContextMenu, handleToggleInProgress }; };