diff --git a/src/@types/Todo.ts b/src/@types/Todo.ts index f24a1fd7..710b5491 100644 --- a/src/@types/Todo.ts +++ b/src/@types/Todo.ts @@ -14,6 +14,7 @@ export interface TodoObject { priority: string | null; hidden: boolean; + inprogress: string | null; due: string | null; dueString: string | null; t: string | null; @@ -50,6 +51,7 @@ export type AttributeKey = | "rec" | "pm" | "hidden" + | "inprogress" | "created" | "completed"; 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 d10306aa..07ccdea3 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: null, lineNumber: 1, body: "", string: "", 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/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/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.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 2f411299..2387a09c 100644 --- a/src/main/DataRequest/CreateTodoObjects.ts +++ b/src/main/DataRequest/CreateTodoObjects.ts @@ -61,6 +61,11 @@ function createTodoObject( const hidden = extensions.some( (extension) => extension.key === "h" && extension.value === "1", ); + 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 = @@ -111,6 +116,7 @@ function createTodoObject( tString, rec, hidden, + inprogress, pm, string: content, }; diff --git a/src/main/DataRequest/DataRequest.ts b/src/main/DataRequest/DataRequest.ts index b97e681c..49aafadb 100644 --- a/src/main/DataRequest/DataRequest.ts +++ b/src/main/DataRequest/DataRequest.ts @@ -49,6 +49,7 @@ function dataRequest(passedSearchString: string = ""): RequestedData | null { rec: {}, pm: {}, hidden: {}, + inprogress: {}, created: {}, completed: {}, }, @@ -62,6 +63,7 @@ function dataRequest(passedSearchString: string = ""): RequestedData | null { rec: [], pm: [], hidden: [], + inprogress: [], created: [], completed: [], }, diff --git a/src/main/DataRequest/Sort.test.ts b/src/main/DataRequest/Sort.test.ts index f88f479f..8e1ed3a9 100644 --- a/src/main/DataRequest/Sort.test.ts +++ b/src/main/DataRequest/Sort.test.ts @@ -268,3 +268,16 @@ describe("rec sorting", () => { }); }); }); + +describe("inprogress sorting", () => { + it("sorts in-progress todos last when inverted", () => { + const todos = [ + createTodoObject(1, "Todo 1 inprogress:1"), + createTodoObject(2, "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 e40604ef..f63bc3a0 100644 --- a/src/main/DataRequest/Sort.ts +++ b/src/main/DataRequest/Sort.ts @@ -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); } 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 27b495da..64947d52 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\b/, + 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: null, + 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 = {}, + setContextMenu: () => void = () => {}, +) => { + const todoObject = { ...baseTodo, ...todo }; + return render( + {}} + setTodoObject={() => {}} + setContextMenu={setContextMenu} + setPromptItem={() => {}} + settings={baseSettings} + onNotification={vi.fn()} + />, + ); +}; + +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(); + }); + + 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 on middle-click", () => { + renderRow(); + fireEvent.mouseDown(screen.getByRole("checkbox"), { button: 1 }); + + expect(mockIpcRendererSend).toHaveBeenCalledTimes(1); + expect(mockIpcRendererSend).toHaveBeenCalledWith( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "1", + true, + ); + }); + + 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( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "", + true, + ); + }); + + it("should not toggle inprogress on middle-click when todo is already complete", () => { + renderRow({ complete: true }); + fireEvent.mouseDown(screen.getByRole("checkbox"), { button: 1 }); + + expect(mockIpcRendererSend).not.toHaveBeenCalled(); + }); + + it("should label the checkbox as in progress when todo is inprogress", () => { + renderRow({ inprogress: "1" }); + + expect(screen.getByRole("checkbox")).toHaveAccessibleName( + "shared.attributeMapping.inprogress", + ); + expect(screen.getByRole("checkbox")).not.toBeChecked(); + }); + + it("should offer mark in progress with a middle-click hint in the context menu", () => { + const item = openRowContextMenu({}).find((i) => i.id === "inprogress"); + + expect(item?.label).toBe("markInProgress"); + expect(item?.hint).toBe("middleClick"); + + item?.function?.(); + expect(mockIpcRendererSend).toHaveBeenCalledWith( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "1", + true, + ); + }); + + it("should offer remove in progress in the context menu when todo is inprogress", () => { + const item = openRowContextMenu({ inprogress: "1" }).find( + (i) => i.id === "inprogress", + ); + + expect(item?.label).toBe("removeInProgress"); + + item?.function?.(); + expect(mockIpcRendererSend).toHaveBeenCalledWith( + "updateTodoObject", + 1, + "Test todo", + "inprogress", + "", + true, + ); + }); + + it("should not offer in progress in the context menu when todo is complete", () => { + const items = openRowContextMenu({ complete: true }); + + expect(items.some((i) => i.id === "inprogress")).toBe(false); + }); +}); diff --git a/src/renderer/Grid/Row.tsx b/src/renderer/Grid/Row.tsx index 7f49c382..4cc0961b 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 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"; @@ -54,7 +55,7 @@ const Row: React.FC = memo( settings, onNotification, }); - const { handleRowContextMenu } = useRowContextMenu({ + const { handleRowContextMenu, handleToggleInProgress } = useRowContextMenu({ setContextMenu, setPromptItem, t, @@ -156,6 +157,10 @@ const Row: React.FC = memo( } }; + const inProgressLabel = todoObject.inprogress + ? t("shared.attributeMapping.inprogress") + : undefined; + return ( <>
  • = 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) => - 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={} - tabIndex={0} - checked={todoObject.complete} - onChange={handleCheckboxChange} - /> + + + ) : ( + + ) + } + 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 && ( @@ -245,7 +263,7 @@ const Row: React.FC = memo( )} {todoObject.hidden && ( - + )} 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 }; };